Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
T
2026-05-26 21:08:27 +08:00

746 lines
32 KiB
Swift

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
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
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)
}
/// 按 spine 索引获取章节的数据访问层。
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) 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 chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
/// 根据持久化位置解析所属章节的数据访问层。
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
bookIdentifier: String?
) -> RDEPUBChapterData? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
return nil
}
return chapterData(for: normalizedLocation.href)
}
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
spineIndex: chapter.spineIndex,
title: chapter.title,
pageCount: chapter.pages.count
)
}
}
/// 按页码(从 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 chapterData = chapterData(for: normalizedLocation.href) else {
return nil
}
if let anchor = normalizedLocation.rangeAnchor?.start,
let page = positionConverter.pageNumber(for: anchor) {
return page
}
if let anchor = indexTable.anchor(for: normalizedLocation),
let page = positionConverter.pageNumber(for: anchor) {
return page
}
return chapterData.pageNumber(for: normalizedLocation)
}
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
return nil
}
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
}
}
// MARK: - 分页书籍构建器
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
///
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
///
/// 特性:
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
/// - 性能采样(记录每章渲染/分页耗时)
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
/// - 封面章节特殊处理
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
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,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.cache = cache
self.layoutConfig = layoutConfig
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,
contentLanguageCode: publication.metadata.language,
pageSize: pageSize,
layoutConfig: layoutConfig
)
// 渲染 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, config: layoutConfig)
: []
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<T: Equatable>(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,
layoutConfigSignature: layoutConfig.cacheSignature
)
}
}