import UIKit /// EPUB 文本书籍构建器,负责将 EPUB publication 渲染、分页并组装为 `RDEPUBTextBook`。 public final class RDEPUBTextBookBuilder { private let renderer: RDEPUBTextRenderer private let cache: RDEPUBTextBookCache? private let layoutConfig: RDEPUBTextLayoutConfig private let sampler: RDEPUBTextPerformanceSampler private let renderPipeline: RDEPUBChapterRenderPipeline private let paginationPipeline: RDEPUBChapterPaginationPipeline private let tailNormalizer: RDEPUBChapterTailNormalizer private let cacheCoordinator: RDEPUBPaginationCacheCoordinator private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter /// 最后一次构建的资源引用诊断(样式表、图片等) public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = [] /// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等) public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = [] /// 最后一次构建的性能采样数据 public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = [] /// 最后一次构建的缓存命中/未命中统计 public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0) /// 创建书籍构建器。 /// - Parameters: /// - renderer: 文本渲染器 /// - cache: 分页缓存(可选) /// - layoutConfig: 页面布局配置 public init( renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil, layoutConfig: RDEPUBTextLayoutConfig = .default ) { self.renderer = renderer self.cache = cache self.layoutConfig = layoutConfig self.sampler = RDEPUBTextPerformanceSampler() self.renderPipeline = RDEPUBChapterRenderPipeline(renderer: renderer) self.paginationPipeline = RDEPUBChapterPaginationPipeline() self.tailNormalizer = RDEPUBChapterTailNormalizer() self.cacheCoordinator = RDEPUBPaginationCacheCoordinator(cache: cache, layoutConfig: layoutConfig) self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter() } /// 默认构造器,使用 DTCoreText 渲染器 public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } private var isPaginationDebugEnabled: Bool { ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") } /// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志 public func phase7SemanticSummary(title: String? = nil) -> String? { diagnosticsReporter.phase7SemanticSummary( title: title, diagnostics: lastBuildPaginationDiagnostics ) } /// 核心构建方法:从 EPUB publication 构建分页书籍。 /// /// 流程: /// 1. 生成缓存键,尝试加载分页缓存 /// 2. 遍历 spine 中的线性 HTML 章节 /// 3. 渲染每章 HTML → NSAttributedString /// 4. 跳过空白的封面/扉页章节 /// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎 /// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页 /// 7. 构建 RDEPUBTextBook 并保存分页缓存 public func build( parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle ) throws -> RDEPUBTextBook { if isPaginationDebugEnabled { print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))") } var chapters: [RDEPUBTextChapter] = [] var flatPages: [RDEPUBTextPage] = [] lastBuildResourceDiagnostics = [] lastBuildPaginationDiagnostics = [] lastBuildPerformanceSamples = [] lastBuildCacheStats = (0, 0) sampler.reset() let buildStart = CFAbsoluteTimeGetCurrent() // 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本 let bookID = publication.metadata.identifier ?? publication.metadata.title let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style) let cachedPagination = cacheCoordinator.load(key: cacheKey) for (spineIndex, item) in publication.spine.enumerated() where item.linear { guard let result = try buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style, chapterIndex: chapters.count, absolutePageStartIndex: flatPages.count, cachedPagination: cachedPagination ) else { continue } if isPaginationDebugEnabled, item.href.contains("Chapter_3.xhtml") { let pages = result.chapter.pages print("[PaginationDebug] href=\(item.href) pages=\(pages.count)") for page in pages { let preview = debugPreview(for: page.content, limit: 36) print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)") for note in page.metadata.diagnostics.prefix(4) { print("[PaginationDebug] note=\(note)") } } } chapters.append(result.chapter) flatPages.append(contentsOf: result.chapter.pages) lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics) lastBuildPaginationDiagnostics.append(result.paginationDiagnostic) sampler.record(result.performanceSample) if result.cacheHit { lastBuildCacheStats.hits += 1 } else { lastBuildCacheStats.misses += 1 } } let book = RDEPUBTextBook(chapters: chapters, pages: flatPages) sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart // 保存分页缓存(只缓存页范围和分页原因,不缓存富文本) cacheCoordinator.save(chapters: chapters, key: cacheKey) #if DEBUG print(sampler.summary()) #endif lastBuildPerformanceSamples = sampler.samples return book } /// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。 public func buildChapter( parser: RDEPUBParser, publication: RDEPUBPublication, spineIndex: Int, pageSize: CGSize, style: RDEPUBTextRenderStyle, chapterIndex: Int = 0, absolutePageStartIndex: Int = 0 ) throws -> RDEPUBTextChapterBuildResult? { let bookID = publication.metadata.identifier ?? publication.metadata.title let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style) let cachedPagination = cacheCoordinator.load(key: cacheKey) return try buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, pageSize: pageSize, style: style, chapterIndex: chapterIndex, absolutePageStartIndex: absolutePageStartIndex, cachedPagination: cachedPagination ) } private func buildChapter( parser: RDEPUBParser, publication: RDEPUBPublication, spineIndex: Int, pageSize: CGSize, style: RDEPUBTextRenderStyle, chapterIndex: Int, absolutePageStartIndex: Int, cachedPagination: [String: RDEPUBTextChapterPaginationCache]? ) throws -> RDEPUBTextChapterBuildResult? { guard publication.spine.indices.contains(spineIndex) else { return nil } let item = publication.spine[spineIndex] guard item.linear, item.mediaType.contains("html") || item.mediaType.contains("xhtml"), let rawHTML = parser.htmlString(forRelativePath: item.href) else { return nil } let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) let request = RDEPUBTextTypesetterPipeline().makeRequest( from: RDEPUBTypesettingInput( href: item.href, title: chapterTitle, rawHTML: rawHTML, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), style: style, resourceResolver: publication.resourceResolver, contentLanguageCode: publication.metadata.language, pageSize: pageSize, layoutConfig: layoutConfig ) ).request let renderStart = CFAbsoluteTimeGetCurrent() let rendered = try renderPipeline.render(request) let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) if item.href.lowercased().contains("cover") { #if DEBUG print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") #endif } if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { if item.href.lowercased().contains("cover") { #if DEBUG print("[EPUB][Cover] skipped href=\(item.href)") #endif } return nil } let content = NSMutableAttributedString(attributedString: rendered.attributedString) let paginateStart = CFAbsoluteTimeGetCurrent() let layoutFrames: [RDEPUBTextLayoutFrame] let isCacheHit: Bool if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) { layoutFrames = [ RDEPUBTextLayoutFrame( contentRange: NSRange(location: 0, length: content.length), breakReason: .chapterEnd, blockRange: nil, attachmentRanges: attachmentRanges(in: content), attachmentKinds: [], blockKinds: [], semanticHints: [], attachmentPlacements: [], trailingFragmentID: nil, diagnostics: [ "page break: chapterEnd", "cover fallback: single attachment page", "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" ] ) ] isCacheHit = false } else if let cached = cachedPagination?[item.href] { layoutFrames = cached.pageRanges.enumerated().map { idx, range in let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit return RDEPUBTextLayoutFrame( contentRange: range, breakReason: breakReason, blockRange: nil, attachmentRanges: [], attachmentKinds: [], blockKinds: [], semanticHints: cached.semanticHints, attachmentPlacements: [], trailingFragmentID: nil, diagnostics: [ "page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit" ] ) } isCacheHit = true } else { layoutFrames = content.length > 0 ? paginationPipeline.frames( for: content, pageSize: pageSize, config: layoutConfig, fragmentOffsets: rendered.fragmentOffsets ) : [] isCacheHit = false } let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart let normalizedFrames = tailNormalizer.normalize( layoutFrames, content: content, href: item.href ) let effectiveFrames = normalizedFrames.isEmpty && content.length > 0 ? [ RDEPUBTextLayoutFrame( contentRange: NSRange(location: 0, length: content.length), breakReason: .chapterEnd, blockRange: nil, attachmentRanges: [], attachmentKinds: [], blockKinds: [], semanticHints: [], attachmentPlacements: [], trailingFragmentID: nil, diagnostics: [ "page break: chapterEnd", "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" ] ) ] : normalizedFrames if item.href.lowercased().contains("cover") { #if DEBUG print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") #endif } let chapterAttributedContent = content.copy() as! NSAttributedString let pages = effectiveFrames.enumerated().map { localPageIndex, frame in let range = frame.contentRange return RDEPUBTextPage( absolutePageIndex: absolutePageStartIndex + localPageIndex, chapterIndex: chapterIndex, spineIndex: spineIndex, href: item.href, chapterTitle: chapterTitle, pageIndexInChapter: localPageIndex, totalPagesInChapter: effectiveFrames.count, chapterContent: chapterAttributedContent, content: content.attributedSubstring(from: range), contentRange: range, pageStartOffset: range.location, pageEndOffset: range.location + max(range.length - 1, 0), metadata: frame.metadata ) } let chapter = RDEPUBTextChapter( chapterIndex: chapterIndex, spineIndex: spineIndex, href: item.href, title: chapterTitle, attributedContent: chapterAttributedContent, fragmentOffsets: rendered.fragmentOffsets, pageBreakReasons: pages.map(\.metadata.breakReason), pages: pages ) let performanceSample = RDEPUBTextPerformanceSample( chapterHref: item.href, renderDuration: renderDuration, paginateDuration: paginateDuration, pageCount: effectiveFrames.count, attributedStringLength: content.length, cacheHit: isCacheHit ) let diagnostic = diagnosticsReporter.chapterDiagnostic( href: item.href, title: chapterTitle, pages: pages ) return RDEPUBTextChapterBuildResult( chapter: chapter, resourceDiagnostics: rendered.resourceDiagnostics, paginationDiagnostic: diagnostic, performanceSample: performanceSample, cacheHit: isCacheHit ) } // MARK: - 章节标题解析 /// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String { if let title = flattenedTOCItems(from: toc).first(where: { tocItem in tocItem.href.components(separatedBy: "#").first == item.href })?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { return title } let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedTitle.isEmpty ? item.href : trimmedTitle } /// 递归展开嵌套目录为扁平列表 private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] { items.flatMap { item in [item] + flattenedTOCItems(from: item.children) } } // MARK: - 章节过滤 /// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件) private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool { let lowercasedHref = item.href.lowercased() var hasAttachment = false if content.length > 0 { content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in guard value != nil else { return } hasAttachment = true stop.pointee = true } } if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) { return true } return false } // MARK: - 附件统计 /// 统计富文本中的附件数量 private func attachmentCount(in content: NSAttributedString) -> Int { guard content.length > 0 else { return 0 } var count = 0 content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in if value != nil { count += 1 } } return count } /// 获取富文本中所有附件的 NSRange 列表 private func attachmentRanges(in content: NSAttributedString) -> [NSRange] { guard content.length > 0 else { return [] } var ranges: [NSRange] = [] content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in if value != nil { ranges.append(range) } } return ranges } // MARK: - 封面章节检测 /// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本) private func isAttachmentOnlyCoverChapter( item: RDEPUBSpineItem, content: NSAttributedString, plainText: String ) -> Bool { let lowercasedHref = item.href.lowercased() guard lowercasedHref.contains("cover") else { return false } let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines) return attachmentCount(in: content) > 0 && trimmed.count <= 1 } private func debugPreview(for content: NSAttributedString, limit: Int) -> String { let collapsed = content.string .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\r", with: " ") .replacingOccurrences(of: "\t", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard !collapsed.isEmpty else { return "" } if collapsed.count <= limit { return collapsed } let head = collapsed.prefix(limit) return "\(head)…" } }