源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
403 lines
18 KiB
Swift
403 lines
18 KiB
Swift
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 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 = 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
|
||
|
||
// 渲染 HTML → NSAttributedString
|
||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||
let rendered = try renderPipeline.render(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
|
||
? 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") {
|
||
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
|
||
)
|
||
}
|
||
|
||
if isPaginationDebugEnabled,
|
||
item.href.contains("Chapter_3.xhtml") {
|
||
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(
|
||
RDEPUBTextChapter(
|
||
chapterIndex: chapterIndex,
|
||
spineIndex: spineIndex,
|
||
href: item.href,
|
||
title: chapterTitle,
|
||
attributedContent: chapterAttributedContent,
|
||
fragmentOffsets: rendered.fragmentOffsets,
|
||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||
pages: pages
|
||
)
|
||
)
|
||
lastBuildPaginationDiagnostics.append(
|
||
diagnosticsReporter.chapterDiagnostic(
|
||
href: item.href,
|
||
title: chapterTitle,
|
||
pages: pages
|
||
)
|
||
)
|
||
flatPages.append(contentsOf: pages)
|
||
}
|
||
|
||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||
|
||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||
|
||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||
cacheCoordinator.save(chapters: chapters, 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
|
||
}
|
||
|
||
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 "<empty>" }
|
||
if collapsed.count <= limit {
|
||
return collapsed
|
||
}
|
||
let head = collapsed.prefix(limit)
|
||
return "\(head)…"
|
||
}
|
||
|
||
}
|