import Foundation final class RDEPUBChapterLoader { private unowned let context: RDEPUBReaderContext private var summaryDiskCache: RDEPUBChapterSummaryDiskCache? init(context: RDEPUBReaderContext) { self.context = context } func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) { summaryDiskCache = cache } // MARK: - 请求优先级 enum LoadPriority { case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列 case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照 } // MARK: - 主入口:加载单个章节 /// 在 chapterLoadQueue 上构建单章,完成后回调到主线程 func loadChapter( spineIndex: Int, store: RDEPUBChapterRuntimeStore, priority: LoadPriority = .navigation, completion: @escaping (Result) -> Void ) { // 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致) if let cached = store.chapterData(for: spineIndex) { RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)") DispatchQueue.main.async { completion(.success(cached)) } return } // 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行, // 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程 store.markBuilding(true) store.chapterLoadQueue.async { RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)") let cacheKey = self.makeCacheKey(spineIndex: spineIndex) // 仅当内存级 pageCountCache 未命中时才查磁盘摘要 let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges let diskSummary: RDEPUBChapterSummary? if precomputedPageRanges == nil { diskSummary = self.summaryDiskCache?.read(for: cacheKey) if diskSummary != nil { RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)") } else { RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)") } } else { diskSummary = nil RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)") } let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange } let availablePageRanges = precomputedPageRanges ?? diskPageRanges do { let chapter = try RDEPUBBackgroundTrace.measure( "ChapterLoader", "buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)" ) { try self.buildChapter( spineIndex: spineIndex, availablePageRanges: availablePageRanges, diskSummary: diskSummary ) } RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)") // 5. 回填缓存 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) // 6. 按优先级处理完成逻辑 switch priority { case .navigation: // 前台导航:检查是否有更新的导航目标(§14.4 取消语义) let nextTarget = store.consumeNavigationTarget() if let target = nextTarget, target != spineIndex { // 当前结果不再是用户目标,丢弃,转而加载新目标 store.markBuilding(false) self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion) return } store.markBuilding(false) DispatchQueue.main.async { completion(.success(chapter)) } case .prefetch: // 后台预取:仅回填缓存,标记预取目标完成 // 不触发跳章,不检查导航目标队列 store.removePrefetchTarget(spineIndex) store.markBuilding(false) DispatchQueue.main.async { completion(.success(chapter)) } } } catch { RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") store.markBuilding(false) DispatchQueue.main.async { completion(.failure(error)) } } } } // MARK: - 同步加载入口(仅供 legacy 位置迁移使用) /// 约束: /// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义 /// - 不允许恢复整书 RDEPUBTextBook /// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用 /// - 调用前必须执行 store.assertNotOnChapterLoadQueue() /// - 只在首次迁移且目标章未命中缓存时使用 func loadChapterSynchronouslyForMigration( spineIndex: Int, store: RDEPUBChapterRuntimeStore? ) throws -> RDEPUBRuntimeChapter { if let cached = store?.chapterData(for: spineIndex) { return cached } guard let store else { throw RDEPUBChapterLoadError.missingParser } store.assertNotOnChapterLoadQueue() var result: Result? let semaphore = DispatchSemaphore(value: 0) RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)") store.chapterLoadQueue.async { do { result = try RDEPUBBackgroundTrace.measure( "ChapterLoader", "sync buildChapter spine=\(spineIndex)" ) { try autoreleasepool { () -> Result in let cacheKey = self.makeCacheKey(spineIndex: spineIndex) 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 ) 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) return .success(chapter) } } } catch { result = .failure(error) } semaphore.signal() } semaphore.wait() return try result!.get() } // MARK: - 单章构建(支持轻量缓存命中后跳过分页) private func buildChapter( spineIndex: Int, availablePageRanges: [NSRange]?, diskSummary: RDEPUBChapterSummary? = nil ) throws -> RDEPUBRuntimeChapter { guard let parser = context.parser, let publication = context.publication else { throw RDEPUBChapterLoadError.missingParser } let pageSize = context.currentTextPageSize() let style = context.currentTextRenderStyle() let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) if let pageRanges = availablePageRanges { // ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ---- RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)") return try buildChapterFromCachedPageRanges( spineIndex: spineIndex, pageRanges: pageRanges, parser: parser, publication: publication, pageSize: pageSize, style: style, layoutConfig: layoutConfig, diskSummary: diskSummary ) } // ---- 完整路径:无缓存,走全量渲染 + 分页 ---- RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)") 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 ) } // MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页 private func buildChapterFromCachedPageRanges( spineIndex: Int, pageRanges: [NSRange], parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle, layoutConfig: RDEPUBTextLayoutConfig, diskSummary: RDEPUBChapterSummary? = nil ) throws -> RDEPUBRuntimeChapter { let spineItem = publication.spine[spineIndex] let href = spineItem.href let title = spineItem.title ?? "" let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent() // 1. 只做 HTML → NSAttributedString 渲染,不做分页 let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: href, title: title, rawHTML: try requireHTMLString(parser, href: href), 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 ) // 2. metadata 来源策略: // - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata // - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断 let metadataSource = diskSummary?.pageMetadataList // 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页) let pages = buildPagesFromRanges( pageRanges: pageRanges, typesetString: typesetString, spineIndex: spineIndex, href: href, title: title, metadataSource: metadataSource ) // 4. 构建 layouter(用于后续可能的重新分页场景) let layouter = RDEPUBTextLayouter( attributedString: typesetString, pageSize: pageSize, config: layoutConfig ) // 5. 构建 chapterOffsetMap let offsetMap = RDEPUBChapterOffsetMap( fragmentOffsets: rendered.fragmentOffsets, pageStartOffsets: pages.map { $0.pageStartOffset }, pageEndOffsets: pages.map { $0.pageEndOffset } ) return RDEPUBRuntimeChapter( spineIndex: spineIndex, href: href, title: title, sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存 typesetAttributedString: typesetString, layouter: layouter, pageRanges: pageRanges, pages: pages, chapterOffsetMap: offsetMap ) } // MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组 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 pageContent = typesetString.attributedSubstring(from: range) let metadata: RDEPUBTextPageMetadata if let metaList = metadataSource, pageIndex < metaList.count { // 从摘要缓存恢复完整 metadata metadata = metaList[pageIndex].toPageMetadata() } else { // 无缓存 metadata,从 attributedString 属性推断 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, content: pageContent, contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + range.length - 1, metadata: metadata ) } } // MARK: - 从 attributedString 的自定义属性推断页 metadata 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: [] ) } // MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter private func assembleRuntimeChapter( from chapter: RDEPUBTextChapter, spineIndex: Int, pageSize: CGSize, layoutConfig: RDEPUBTextLayoutConfig ) 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 } ) let pageRanges = chapter.pages.map { $0.contentRange } // 回填磁盘摘要(P2 阶段生效) let cacheKey = makeCacheKey(spineIndex: spineIndex) let summary = RDEPUBChapterSummary( pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) summaryDiskCache?.write(summary: summary, 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 ) } // MARK: - 缓存键 private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey { let style = context.currentTextRenderStyle() let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize()) // renderSignature 必须覆盖 §8.2 定义的全部参数 let 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) return RDEPUBChapterCacheKey( bookID: context.currentBookIdentifier ?? "", spineIndex: spineIndex, renderSignature: renderSignature, chapterContentHash: contentHash ) } private func contentHashForSpineIndex(_ spineIndex: Int) -> 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.sha256Hex } private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String { guard let html = parser.htmlString(forRelativePath: href) else { throw RDEPUBChapterLoadError.emptyChapterHref(href) } return html } } 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)。" } } }