import UIKit // MARK: - 章节分页诊断数据 /// 单个章节的分页诊断信息,用于调试和质量检测。 /// 记录页数、分页原因、附件/块级元素统计等。 public struct RDEPUBTextChapterPaginationDiagnostic: Equatable { public var href: String public var title: String public var pageCount: Int /// 每页的分页原因列表 public var breakReasons: [RDEPUBTextPageBreakReason] /// 包含附件的页数 public var attachmentPageCount: Int /// 因块级/附件边界调整而分页的页数 public var blockAdjustedPageCount: Int public var blockKinds: [RDEPUBTextBlockKind] public var semanticHints: [RDEPUBTextSemanticHint] public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] /// 采样诊断日志(最多 4 条) public var sampleNotes: [String] } // MARK: - 页面数据模型 /// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。 /// /// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成, /// `contentRange` 标记了该页在章节富文本中的字符范围。 public struct RDEPUBTextPage: Equatable { /// 全书绝对页码(从 0 开始) public var absolutePageIndex: Int public var chapterIndex: Int public var spineIndex: Int public var href: String public var chapterTitle: String /// 该页在所属章节中的相对页码(从 0 开始) public var pageIndexInChapter: Int public var totalPagesInChapter: Int /// 所属章节的完整富文本内容(用于跨页查询) public var chapterContent: NSAttributedString /// 该页的富文本片段 public var content: NSAttributedString /// 该页在章节富文本中的范围 public var contentRange: NSRange public var pageStartOffset: Int public var pageEndOffset: Int /// 页的元数据(分页原因、附件、语义标记等) public var metadata: RDEPUBTextPageMetadata } // MARK: - 章节数据模型 /// 渲染并分页后的单个章节,包含完整富文本和分页结果。 public struct RDEPUBTextChapter: Equatable { public var chapterIndex: Int public var spineIndex: Int public var href: String public var title: String /// 章节完整富文本内容 public var attributedContent: NSAttributedString /// fragment ID → 字符偏移量映射(用于锚点定位) public var fragmentOffsets: [String: Int] public var pageBreakReasons: [RDEPUBTextPageBreakReason] public var pages: [RDEPUBTextPage] } // MARK: - 分页书籍模型 /// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。 /// /// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。 /// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。 public struct RDEPUBTextBook { public var chapters: [RDEPUBTextChapter] public var pages: [RDEPUBTextPage] /// 全局索引表:fileIndex/row/column → 绝对字符偏移量 public let indexTable: RDEPUBTextIndexTable public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) { self.chapters = chapters self.pages = pages self.indexTable = RDEPUBTextIndexTable(chapters: chapters) } public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool { lhs.chapters == rhs.chapters && lhs.pages == rhs.pages } /// 按 href 获取章节的数据访问层(包含索引表) public func chapterData(for href: String) -> RDEPUBChapterData? { guard let chapter = chapters.first(where: { $0.href == href }) else { return nil } return RDEPUBChapterData(chapter: chapter, indexTable: indexTable) } /// 按章节序号获取章节的数据访问层 public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? { guard chapters.indices.contains(index) else { return nil } return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable) } /// 按页码(从 1 开始)获取对应页面 public func page(at pageNumber: Int) -> RDEPUBTextPage? { guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil } return pages[pageNumber - 1] } /// 根据持久化位置计算对应页码(从 1 开始)。 /// /// 解析优先级: /// 1. rangeAnchor 锚点定位 /// 2. fragment 片段 ID /// 3. navigationProgression 进度百分比回退 public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? { guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier), let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else { return nil } if let anchor = normalizedLocation.rangeAnchor?.start, let page = indexTable.pageNumber(for: anchor, in: self) { return page + 1 } let targetOffset: Int if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] { targetOffset = fragmentOffset } else { let lastOffset = max(chapter.attributedContent.length - 1, 0) targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression)))) } if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) { return page.absolutePageIndex + 1 } return chapter.pages.last.map { $0.absolutePageIndex + 1 } } /// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点 public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? { guard let page = page(at: pageNumber), let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else { return nil } let totalLength = max(chapter.attributedContent.length - 1, 1) let startAnchor = indexTable.anchor(forAbsoluteIndex: page.pageStartOffset, in: chapter) let endAnchor = indexTable.anchor(forAbsoluteIndex: page.pageEndOffset, in: chapter) let rangeAnchor = RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor) return RDEPUBLocation( bookIdentifier: bookIdentifier, href: page.href, progression: Double(page.pageStartOffset) / Double(totalLength), lastProgression: Double(page.pageEndOffset) / Double(totalLength), fragment: nil, rangeAnchor: rangeAnchor ) } } // MARK: - 分页书籍构建器 /// 从 EPUB publication 构建分页书籍模型的核心构建器。 /// /// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook /// /// 特性: /// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本) /// - 性能采样(记录每章渲染/分页耗时) /// - 尾页规范化(丢弃纯空白尾页、合并过短尾页) /// - 封面章节特殊处理 public final class RDEPUBTextBookBuilder { private let renderer: RDEPUBTextRenderer private let cache: RDEPUBTextBookCache? private let sampler: RDEPUBTextPerformanceSampler /// 最后一次构建的资源引用诊断(样式表、图片等) 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) public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) { self.renderer = renderer self.cache = cache self.sampler = RDEPUBTextPerformanceSampler() } /// 默认构造器,使用 DTCoreText 渲染器 public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } /// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志 public func phase7SemanticSummary(title: String? = nil) -> String? { guard !lastBuildPaginationDiagnostics.isEmpty else { return nil } let blockKinds = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.blockKinds)) let semanticHints = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.semanticHints)) let attachmentPlacements = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.attachmentPlacements)) let note = lastBuildPaginationDiagnostics .flatMap(\.sampleNotes) .first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") }) var parts = [ title, "章节 \(lastBuildPaginationDiagnostics.count)", blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]", semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]", attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]" ].compactMap { $0 } if let note { parts.append(note) } return parts.joined(separator: " · ") } /// 核心构建方法:从 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 { 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 = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style) let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) } for (spineIndex, item) in publication.spine.enumerated() where item.linear { guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"), let rawHTML = parser.htmlString(forRelativePath: item.href) else { continue } // 从目录表中解析章节标题 let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) let request = RDEPUBTextRendererSupport.makeChapterRenderRequest( href: item.href, title: chapterTitle, rawHTML: rawHTML, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), style: style, resourceResolver: publication.resourceResolver ) // 渲染 HTML → NSAttributedString let renderStart = CFAbsoluteTimeGetCurrent() let rendered = try renderer.renderChapter(request: request) let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics) let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) if item.href.lowercased().contains("cover") { print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") } // 跳过空白的封面/扉页章节 if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { if item.href.lowercased().contains("cover") { print("[EPUB][Cover] skipped href=\(item.href)") } continue } let chapterIndex = chapters.count 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] { // 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页 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 { // 缓存未命中:调用 CoreText 分页引擎 layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets) : [] isCacheHit = false } let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart // 尾页规范化:丢弃纯空白尾页、合并过短尾页 let normalizedFrames = normalizeTrailingFrames( 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") { print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") } // 记录性能采样 sampler.record(RDEPUBTextPerformanceSample( chapterHref: item.href, renderDuration: renderDuration, paginateDuration: paginateDuration, pageCount: effectiveFrames.count, attributedStringLength: content.length, cacheHit: isCacheHit )) if isCacheHit { lastBuildCacheStats.hits += 1 } else { lastBuildCacheStats.misses += 1 } // 构建页面和章节模型 let chapterAttributedContent = content.copy() as! NSAttributedString let pages = effectiveFrames.enumerated().map { localPageIndex, frame in let range = frame.contentRange return RDEPUBTextPage( absolutePageIndex: flatPages.count + 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 ) } chapters.append( RDEPUBTextChapter( chapterIndex: chapterIndex, spineIndex: spineIndex, href: item.href, title: chapterTitle, attributedContent: chapterAttributedContent, fragmentOffsets: rendered.fragmentOffsets, pageBreakReasons: pages.map(\.metadata.breakReason), pages: pages ) ) lastBuildPaginationDiagnostics.append( RDEPUBTextChapterPaginationDiagnostic( href: item.href, title: chapterTitle, pageCount: pages.count, breakReasons: pages.map(\.metadata.breakReason), attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count, blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count, blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)), semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)), attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)), sampleNotes: Array( pages .flatMap(\.metadata.diagnostics) .prefix(4) ) ) ) flatPages.append(contentsOf: pages) } let book = RDEPUBTextBook(chapters: chapters, pages: flatPages) sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart // 保存分页缓存(只缓存页范围和分页原因,不缓存富文本) if let cacheKey { let paginationCache = chapters.map { chapter in let pageRanges = chapter.pages.map(\.contentRange) let breakReasons = chapter.pages.map(\.metadata.breakReason) let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints))) return RDEPUBTextChapterPaginationCache( href: chapter.href, pageRanges: pageRanges, breakReasons: breakReasons, semanticHints: semanticHints ) } cache?.save(paginationCache, key: cacheKey) } print(sampler.summary()) lastBuildPerformanceSamples = sampler.samples return book } // 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 } // MARK: - 尾页规范化 /// 规范化分页结果的尾部页面: /// 1. 丢弃纯空白的尾页 /// 2. 将过短的尾页(≤2 字符)合并到前一页 private func normalizeTrailingFrames( _ frames: [RDEPUBTextLayoutFrame], content: NSAttributedString, href: String ) -> [RDEPUBTextLayoutFrame] { guard frames.count > 1 else { return frames } var normalized = frames // 丢弃纯空白的尾页 while let lastFrame = normalized.last, shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) { normalized.removeLast() let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))" if var previousFrame = normalized.popLast() { previousFrame.diagnostics.append(note) normalized.append(previousFrame) } else { print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))") } } // 将过短的尾页合并到前一页 guard normalized.count > 1, let lastFrame = normalized.last, let previousFrame = normalized.dropLast().last, shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else { return normalized } let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame) normalized.removeLast(2) normalized.append(mergedFrame) return normalized } /// 判断尾页是否为纯空白(无可显示字符且无附件) private func shouldDropWhitespaceOnlyTrailingFrame( _ frame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { guard frame.contentRange.length > 0, attachmentCount(in: content, range: frame.contentRange) == 0 else { return false } return visibleCharacterCount(in: content, range: frame.contentRange) == 0 } /// 判断尾页是否过短需要合并到前一页。 /// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符) private func shouldMergeShortTrailingFrame( _ trailingFrame: RDEPUBTextLayoutFrame, previousFrame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { guard trailingFrame.contentRange.length > 0, NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else { return false } let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange) let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange) guard visibleCount <= 2, trailingFrame.contentRange.length <= 2, visibleCount > 0 || trailingAttachmentCount > 0 else { return false } let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange) return previousVisibleCount >= max(visibleCount * 8, 12) } /// 将尾页合并到前一页,保留尾页的分页原因 private func mergeTrailingFrame( _ previousFrame: RDEPUBTextLayoutFrame, with trailingFrame: RDEPUBTextLayoutFrame ) -> RDEPUBTextLayoutFrame { let mergedRange = NSRange( location: previousFrame.contentRange.location, length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location ) return RDEPUBTextLayoutFrame( contentRange: mergedRange, breakReason: trailingFrame.breakReason, blockRange: trailingFrame.blockRange ?? previousFrame.blockRange, attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges), attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds), blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds), semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints), attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements), trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID, diagnostics: previousFrame.diagnostics + trailingFrame.diagnostics + ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"] ) } /// 统计指定范围内可见字符数(排除空白和控制字符) private func visibleCharacterCount( in content: NSAttributedString, range: NSRange ) -> Int { guard range.length > 0 else { return 0 } let string = content.attributedSubstring(from: range).string let filteredScalars = string.unicodeScalars.filter { scalar in !CharacterSet.whitespacesAndNewlines.contains(scalar) && !CharacterSet.controlCharacters.contains(scalar) } return filteredScalars.count } // MARK: - 集合工具方法 /// 数组去重(保持顺序) private func uniqueValues(from values: [T]) -> [T] { values.reduce(into: [T]()) { result, value in if !result.contains(value) { result.append(value) } } } /// NSRange 数组去重(保持顺序) private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] { ranges.reduce(into: [NSRange]()) { result, value in if !result.contains(value) { result.append(value) } } } /// 统计指定范围内附件数量 private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int { guard content.length > 0, range.length > 0 else { return 0 } var count = 0 content.enumerateAttribute(.attachment, in: range) { value, _, _ in if value != nil { count += 1 } } return count } // MARK: - 缓存键生成 /// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希 private func makeCacheKey( bookID: String, pageSize: CGSize, style: RDEPUBTextRenderStyle ) -> String? { guard let cache else { return nil } return cache.cacheKey( bookID: bookID, fontSize: style.font.pointSize, lineHeightMultiple: style.lineSpacing, contentInsets: .zero, pageSize: pageSize ) } }