refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
|
||||
///
|
||||
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
|
||||
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
|
||||
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
|
||||
public final class RDEPUBChapterData {
|
||||
/// 底层章节模型(只读)
|
||||
public let chapter: RDEPUBTextChapter
|
||||
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
@@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
// MARK: - 便捷属性(转发自 chapter)
|
||||
|
||||
/// 章节在全书中的序号
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
/// spine 顺序索引
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
|
||||
public var href: String { chapter.href }
|
||||
/// 章节标题
|
||||
public var title: String { chapter.title }
|
||||
/// 章节完整的富文本内容
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
/// 分页后的页面列表
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
|
||||
// MARK: - 页面查询
|
||||
|
||||
/// 查找包含指定绝对字符偏移量的页面
|
||||
/// - Parameter absoluteOffset: 全书绝对字符偏移量
|
||||
/// - Returns: 包含该偏移量的页面,未找到返回 nil
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
/// 按绝对页码查找页面
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
// MARK: - 锚点与位置映射
|
||||
|
||||
/// 将绝对字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为起止锚点对
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
|
||||
/// - Parameters:
|
||||
/// - absoluteRange: 选区在全书中的字符范围
|
||||
/// - bookIdentifier: 书籍标识符
|
||||
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
|
||||
@@ -56,6 +88,7 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
@@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将页面转换为对应的 RDEPUBLocation
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
// MARK: - 位置反向解析(Location → 绝对偏移量)
|
||||
|
||||
/// 将持久化位置还原为章节内的绝对字符范围。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor(锚点定位,最精确)
|
||||
/// 2. fragment(片段 ID 定位)
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
@@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
@@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
@@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 私有工具方法
|
||||
|
||||
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
|
||||
@@ -4,9 +4,18 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
|
||||
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
|
||||
///
|
||||
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
|
||||
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
|
||||
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
/// DTCoreText 库是否在当前编译环境中可用
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
@@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
|
||||
///
|
||||
/// 渲染流程:
|
||||
/// 1. 将 HTML 字符串编码为 Data
|
||||
/// 2. 通过 DTCoreText 解析为富文本(失败则回退到纯文本)
|
||||
/// 3. 注入分页语义标记(${rd-sem-start/end} → 属性字典)
|
||||
/// 4. 提取 fragment 偏移量映射表
|
||||
/// 5. 规范化阅读属性(字体、行距、颜色统一)
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
@@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return try renderChapter(request: request)
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
@@ -71,6 +90,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
|
||||
///
|
||||
/// 通过 `willFlushCallback` 回调,在每个 DOM 元素最终写入富文本前,
|
||||
/// 对图片附件做尺寸规范化、判断是否为脚注/封面等特殊元素。
|
||||
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
@@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
|
||||
///
|
||||
/// - 注意:行高倍率计算公式为 `(字体行高 + 行间距) / 字体行高`,
|
||||
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||
|
||||
@@ -1,48 +1,80 @@
|
||||
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]) {
|
||||
@@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
|
||||
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
|
||||
@@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
|
||||
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 {
|
||||
@@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
|
||||
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 {
|
||||
@@ -121,14 +163,29 @@ public struct RDEPUBTextBook {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -137,10 +194,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
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 }
|
||||
|
||||
@@ -164,6 +223,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
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,
|
||||
@@ -180,7 +249,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// Cache lookup — WXRead style: load per-chapter page ranges
|
||||
// 缓存查询 — 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) }
|
||||
@@ -191,6 +260,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: item.href,
|
||||
@@ -201,6 +271,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
resourceResolver: publication.resourceResolver
|
||||
)
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
@@ -211,6 +282,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
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)")
|
||||
@@ -221,10 +293,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
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),
|
||||
@@ -245,7 +319,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// Cache hit: use cached page ranges, skip rd_paginatedFrames
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
@@ -263,6 +337,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
||||
: []
|
||||
@@ -270,6 +345,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = normalizeTrailingFrames(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
@@ -298,6 +374,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
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,
|
||||
@@ -312,6 +389,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
@@ -369,6 +447,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
if let cacheKey {
|
||||
let paginationCache = chapters.map { chapter in
|
||||
let pageRanges = chapter.pages.map(\.contentRange)
|
||||
@@ -390,6 +469,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
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
|
||||
@@ -400,12 +482,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
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
|
||||
@@ -422,6 +508,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - 附件统计
|
||||
|
||||
/// 统计富文本中的附件数量
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@@ -433,6 +522,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
/// 获取富文本中所有附件的 NSRange 列表
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
@@ -444,6 +534,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - 封面章节检测
|
||||
|
||||
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
@@ -455,6 +548,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
// MARK: - 尾页规范化
|
||||
|
||||
/// 规范化分页结果的尾部页面:
|
||||
/// 1. 丢弃纯空白的尾页
|
||||
/// 2. 将过短的尾页(≤2 字符)合并到前一页
|
||||
private func normalizeTrailingFrames(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
@@ -464,6 +562,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
var normalized = frames
|
||||
|
||||
// 丢弃纯空白的尾页
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
@@ -476,6 +575,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// 将过短的尾页合并到前一页
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
@@ -489,6 +589,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return normalized
|
||||
}
|
||||
|
||||
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
||||
private func shouldDropWhitespaceOnlyTrailingFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
@@ -500,6 +601,8 @@ public final class RDEPUBTextBookBuilder {
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
/// 判断尾页是否过短需要合并到前一页。
|
||||
/// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符)
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
@@ -522,6 +625,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
/// 将尾页合并到前一页,保留尾页的分页原因
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
@@ -547,6 +651,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/// 统计指定范围内可见字符数(排除空白和控制字符)
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
@@ -560,6 +665,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
// MARK: - 集合工具方法
|
||||
|
||||
/// 数组去重(保持顺序)
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@@ -568,6 +676,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NSRange 数组去重(保持顺序)
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@@ -576,6 +685,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计指定范围内附件数量
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@@ -587,6 +697,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: - 缓存键生成
|
||||
|
||||
/// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
|
||||
// MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
|
||||
/// Per-chapter pagination metadata cached to disk.
|
||||
/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons,
|
||||
/// NOT the full attributed string. Builder re-renders HTML on cache hit but
|
||||
/// skips the rd_paginatedFrames pagination step.
|
||||
/// 单个章节的分页元数据缓存,用于磁盘持久化。
|
||||
///
|
||||
/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
|
||||
/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
|
||||
public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
/// 章节文件相对路径
|
||||
public var href: String
|
||||
/// 每页在章节富文本中的字符范围
|
||||
public var pageRanges: [NSRange]
|
||||
/// 每页的分页原因(语义边界、帧限制等)
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
@@ -26,8 +29,10 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSCoding Archive (simple: strings + ints only)
|
||||
// MARK: - NSCoding 归档层(只使用字符串和整数类型)
|
||||
|
||||
/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。
|
||||
/// 包含所有章节的分页归档数据。
|
||||
final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
@@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
|
||||
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
let href: String
|
||||
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
|
||||
let rangeLocations: [NSNumber]
|
||||
/// 页范围的长度数组
|
||||
let rangeLengths: [NSNumber]
|
||||
/// 分页原因的原始字符串数组
|
||||
let breakReasons: [String]
|
||||
/// 语义提示的原始字符串数组
|
||||
let semanticHints: [String]
|
||||
|
||||
/// 从缓存模型构建归档对象
|
||||
init(from cache: RDEPUBTextChapterPaginationCache) {
|
||||
self.href = cache.href
|
||||
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
|
||||
@@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
|
||||
/// 将归档数据转换回缓存模型
|
||||
func toCache() -> RDEPUBTextChapterPaginationCache {
|
||||
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
|
||||
NSRange(location: loc.intValue, length: len.intValue)
|
||||
@@ -99,19 +111,27 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RDEPUBTextBookCache
|
||||
// MARK: - 分页缓存管理器
|
||||
|
||||
/// Disk-persistent cache for per-chapter pagination metadata.
|
||||
/// Follows WXRead's WRChapterPageCount caching pattern:
|
||||
/// cache key = bookID + layout settings, cache value = page NSRange per chapter.
|
||||
/// Attributed strings are NOT cached — builder re-renders HTML on cache hit.
|
||||
/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
|
||||
///
|
||||
/// 缓存策略:
|
||||
/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
|
||||
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
|
||||
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
|
||||
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
/// 缓存模式版本号,变更时旧缓存自动失效
|
||||
public var schemaVersion: Int = 1
|
||||
|
||||
/// 串行队列,保证缓存读写的线程安全
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||
/// 缓存文件目录
|
||||
private let cacheDirectory: URL
|
||||
|
||||
/// 初始化缓存管理器,自动创建缓存目录
|
||||
/// - Parameter subdirectory: Caches 目录下的子目录名
|
||||
public init(subdirectory: String = "RDEPUBTextBookCache") {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
@@ -120,10 +140,13 @@ public final class RDEPUBTextBookCache {
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Cache Key
|
||||
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
// MARK: - 缓存键生成
|
||||
// 对标 WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
|
||||
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
|
||||
///
|
||||
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
@@ -137,10 +160,10 @@ public final class RDEPUBTextBookCache {
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
// MARK: - Load / Save (pagination metadata only)
|
||||
// MARK: - 加载/保存(只缓存分页元数据)
|
||||
|
||||
/// Load cached pagination metadata for all chapters.
|
||||
/// Returns dictionary keyed by chapter href.
|
||||
/// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
|
||||
/// 缓存未命中或反序列化失败时返回 nil。
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@@ -170,7 +193,7 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save pagination metadata for all chapters.
|
||||
/// 将分页元数据保存到磁盘(原子写入,防止损坏)
|
||||
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@@ -186,8 +209,9 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invalidate
|
||||
// MARK: - 缓存失效
|
||||
|
||||
/// 清除所有缓存文件
|
||||
public func invalidateAll() {
|
||||
queue.sync {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// RDEPUBTextIndexTable.swift
|
||||
// EPUB 文本索引表
|
||||
// 构建全书的文本索引映射:章节起始偏移、href 与章节/spine 索引的对应关系、
|
||||
// fragment 偏移映射、以及行列索引映射。支持锚点与位置之间的双向转换,
|
||||
// 用于精确定位文本选择和搜索匹配。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 行-列索引条目,表示文本中某一行的字符范围
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 该行的起始字符偏移
|
||||
public let startOffset: Int
|
||||
/// 该行的结束字符偏移
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
self.row = row
|
||||
self.startOffset = startOffset
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
/// 判断给定偏移量是否在该行范围内
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本索引表,存储全书的文本结构映射
|
||||
public struct RDEPUBTextIndexTable {
|
||||
/// 各章节的起始绝对偏移量(累加长度)
|
||||
public let chapterStartOffsets: [Int]
|
||||
/// 各章节的文本长度
|
||||
public let chapterLengths: [Int]
|
||||
/// href → 章节索引的映射
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
/// href → spine 文件索引的映射
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
/// href → (fragment ID → 章节内偏移) 的映射
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
/// spine 文件索引 → href 的反向映射
|
||||
public let fileIndexToHref: [Int: String]
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
var hrefMap: [String: Int] = [:]
|
||||
var hrefToFileMap: [String: Int] = [:]
|
||||
var fragmentMap: [String: [String: Int]] = [:]
|
||||
var fileIndexMap: [Int: String] = [:]
|
||||
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
|
||||
var running = 0
|
||||
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
hrefMap[chapter.href] = index
|
||||
hrefToFileMap[chapter.href] = chapter.spineIndex
|
||||
offsets.append(running)
|
||||
lengths.append(chapter.attributedContent.length)
|
||||
running += chapter.attributedContent.length
|
||||
fragmentMap[chapter.href] = chapter.fragmentOffsets
|
||||
fileIndexMap[chapter.spineIndex] = chapter.href
|
||||
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
|
||||
}
|
||||
|
||||
self.chapterStartOffsets = offsets
|
||||
self.chapterLengths = lengths
|
||||
self.hrefToChapterIndex = hrefMap
|
||||
self.hrefToFileIndex = hrefToFileMap
|
||||
self.fragmentOffsetsByHref = fragmentMap
|
||||
self.fileIndexToHref = fileIndexMap
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
}
|
||||
|
||||
/// 根据绝对索引和章节构建文本锚点
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, in: chapter)
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
||||
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: chapter.spineIndex,
|
||||
row: row,
|
||||
column: column,
|
||||
chapterOffset: normalizedIndex,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据阅读位置构建文本锚点(优先使用 rangeAnchor,其次根据 fragment 或 progression 计算)
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
}
|
||||
|
||||
guard let chapterIndex = hrefToChapterIndex[location.href],
|
||||
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
||||
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
||||
let chapterOffset: Int
|
||||
|
||||
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
|
||||
chapterOffset = fragmentOffset
|
||||
} else {
|
||||
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
|
||||
let lastOffset = max(estimatedLength - 1, 0)
|
||||
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
}
|
||||
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: location.fragment
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = absoluteIndex(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
/// 通过文件索引获取 href
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
/// 通过 href 获取章节索引
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let absoluteOffset = absoluteIndex(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: progression,
|
||||
lastProgression: progression,
|
||||
fragment: anchor.fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
|
||||
)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为阅读位置(起止 progression)
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: Double(clampedStart) / Double(totalLength),
|
||||
lastProgression: Double(clampedEnd) / Double(totalLength),
|
||||
fragment: rangeAnchor.start.fragmentID,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在行号
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
return rowIndex
|
||||
}
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在列号(行内偏移)
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
return max(index - rowEntry.startOffset, 0)
|
||||
}
|
||||
guard let lastRow = rows.last else { return 0 }
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 查找距离给定偏移量最近(且在其之前)的 fragment ID
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
|
||||
for (id, fragOffset) in chapter.fragmentOffsets {
|
||||
if fragOffset <= offset && fragOffset > bestOffset {
|
||||
bestOffset = fragOffset
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else {
|
||||
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
|
||||
}
|
||||
|
||||
var rows: [RDEPUBRowColumnIndex] = []
|
||||
var rowNumber = 0
|
||||
var lineStart = 0
|
||||
|
||||
nsText.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: length),
|
||||
options: [.byLines, .substringNotRequired]
|
||||
) { _, substringRange, enclosingRange, _ in
|
||||
let startOffset = enclosingRange.location
|
||||
let lineLength = max(substringRange.length, 0)
|
||||
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
|
||||
rows.append(
|
||||
RDEPUBRowColumnIndex(
|
||||
row: rowNumber,
|
||||
startOffset: startOffset,
|
||||
endOffset: min(endOffset, max(length - 1, 0))
|
||||
)
|
||||
)
|
||||
rowNumber += 1
|
||||
lineStart = enclosingRange.location + enclosingRange.length
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
|
||||
} else if lineStart == length, text.hasSuffix("\n") {
|
||||
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// CoreText 分页引擎产生的单帧(单页)布局数据。
|
||||
///
|
||||
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
|
||||
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
|
||||
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
|
||||
struct RDEPUBTextLayoutFrame: Equatable {
|
||||
/// 该帧在章节富文本中的字符范围
|
||||
var contentRange: NSRange
|
||||
/// 分页原因(语义边界、附件边界、帧限制等)
|
||||
var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 所属 HTML 块级元素的范围(用于调试)
|
||||
var blockRange: NSRange?
|
||||
/// 帧内附件的字符范围列表
|
||||
var attachmentRanges: [NSRange]
|
||||
/// 帧内附件类型列表(图片、通用附件等)
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 帧内附件的布局方式(行内、基线、居中)
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
|
||||
var trailingFragmentID: String?
|
||||
/// 诊断日志列表(分页原因详情、范围信息等)
|
||||
var diagnostics: [String]
|
||||
|
||||
/// 将布局帧数据转换为页元数据模型
|
||||
var metadata: RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: breakReason,
|
||||
|
||||
@@ -5,11 +5,29 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 分页引擎:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
|
||||
///
|
||||
/// 分页策略优先级(从高到低):
|
||||
/// 1. avoidPageBreakInside — 不在保护块内分页(对标 WXRead 的行级回退扫描)
|
||||
/// 2. keepWithNext — 标题等元素需与下一段同页
|
||||
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
|
||||
/// 4. pageRelate — 微信读书式的跨页关联元素
|
||||
/// 5. 附件边界 — 块级附件应整体移到下一页
|
||||
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
|
||||
///
|
||||
/// 支持两条渲染路径:
|
||||
/// - DTCoreText 路径(首选):DTCoreTextLayouter → DTCoreTextLayoutFrame
|
||||
/// - CoreText 回退路径:CTFramesetterCreateFrame
|
||||
struct RDEPUBTextLayouter {
|
||||
/// 待分页的富文本
|
||||
private let attributedString: NSAttributedString
|
||||
/// 页面尺寸(决定每帧能容纳多少内容)
|
||||
private let pageSize: CGSize
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
/// 页面矩形路径(用于 CTFrame 排版)
|
||||
private let path: CGPath
|
||||
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
@@ -20,6 +38,8 @@ struct RDEPUBTextLayouter {
|
||||
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
/// 根据编译环境选择 DTCoreText 或 CoreText 路径。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@@ -32,6 +52,11 @@ struct RDEPUBTextLayouter {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - CoreText 分页路径(回退方案)
|
||||
|
||||
/// 使用原生 CoreText API 进行分页。
|
||||
///
|
||||
/// 流程:CTFramesetterCreateFrame → 获取可视范围 → 语义边界调整 → 记录帧
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@@ -49,7 +74,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: frame)
|
||||
@@ -89,7 +114,10 @@ struct RDEPUBTextLayouter {
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
@@ -152,6 +180,18 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 语义边界调整
|
||||
|
||||
/// 对 CoreText 提出的分页范围进行语义边界调整。
|
||||
///
|
||||
/// 调整优先级:
|
||||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||||
/// 2. 优先在语义边界(pageBreakBefore/After)分页
|
||||
/// 3. 其次在 pageRelate 跨页关联点分页
|
||||
/// 4. 再次在附件边界分页(块级附件需整体移动)
|
||||
/// 5. 以上都不满足时,使用原始帧限制分页
|
||||
///
|
||||
/// 最小分页长度约束:不低于原始范围的 55%,避免单页内容过少。
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
@@ -193,6 +233,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 最小分页长度:原始范围的 55%
|
||||
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
|
||||
let minimumEnd = proposedRange.location + minLength
|
||||
|
||||
@@ -203,6 +244,7 @@ struct RDEPUBTextLayouter {
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 1. 语义边界(pageBreakBefore/After)
|
||||
if let semanticBoundary = preferredSemanticBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@@ -230,6 +272,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. pageRelate 跨页关联边界
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: minimumEnd,
|
||||
@@ -258,6 +301,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 附件边界(块级附件整体移动)
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@@ -284,6 +328,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 4. 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@@ -305,6 +350,11 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
///
|
||||
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
|
||||
private func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int
|
||||
@@ -337,6 +387,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
||||
/// 行内脚注图标等不应导致整段移动。
|
||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
@@ -346,9 +398,8 @@ struct RDEPUBTextLayouter {
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// Mirror WXRead more closely: only block-level attachments should
|
||||
// push the entire block to the next page. Inline footnote icons and
|
||||
// other inline attachments must not cause a whole paragraph to move.
|
||||
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
||||
// 行内脚注图标等行内附件不应导致整段移动。
|
||||
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
@@ -361,6 +412,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
|
||||
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
@@ -384,6 +437,9 @@ struct RDEPUBTextLayouter {
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
// MARK: - 属性查询工具
|
||||
|
||||
/// 获取指定位置的块级元素范围
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -393,6 +449,7 @@ struct RDEPUBTextLayouter {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 获取指定位置的块级元素类型
|
||||
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -400,6 +457,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取指定位置的附件布局方式
|
||||
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -407,6 +465,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取包含指定位置的段落范围
|
||||
private func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
@@ -414,6 +473,7 @@ struct RDEPUBTextLayouter {
|
||||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||||
}
|
||||
|
||||
/// 获取指定范围内的所有附件字符范围
|
||||
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
|
||||
@@ -423,6 +483,7 @@ struct RDEPUBTextLayouter {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 获取指定位置的语义提示列表
|
||||
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -432,6 +493,7 @@ struct RDEPUBTextLayouter {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||
@@ -445,6 +507,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
||||
@@ -458,6 +521,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
||||
@@ -469,6 +533,7 @@ struct RDEPUBTextLayouter {
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
||||
@@ -482,6 +547,7 @@ struct RDEPUBTextLayouter {
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
private func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
@@ -492,11 +558,11 @@ struct RDEPUBTextLayouter {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
|
||||
// MARK: - 行级 avoidPageBreakInside(WXRead 方案)
|
||||
|
||||
/// Scans CTFrame lines backward from the last line, removing trailing lines
|
||||
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
|
||||
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
/// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
|
||||
/// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@@ -509,8 +575,8 @@ struct RDEPUBTextLayouter {
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
// Scan backward: count consecutive trailing lines inside protected blocks
|
||||
// kMaxLinesToRemove = 3 (same as WXRead)
|
||||
// 从最后一行向前扫描,统计落在保护块内的连续尾部行数
|
||||
// kMaxLinesToRemove = 3(与 WXRead 一致)
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
@@ -521,8 +587,7 @@ struct RDEPUBTextLayouter {
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// Don't remove more than kMaxLinesToRemove; stop here
|
||||
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
|
||||
// 不超过 kMaxLinesToRemove 行,停在这里
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
@@ -535,7 +600,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// All lines are in protected blocks; fall back to proposed
|
||||
// 所有行都在保护块内,回退到原始范围
|
||||
return proposed
|
||||
}
|
||||
|
||||
@@ -549,6 +614,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@@ -561,7 +627,10 @@ struct RDEPUBTextLayouter {
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 行级处理
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@@ -603,6 +672,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@@ -615,6 +685,8 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
/// 最多移除 3 行。
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@@ -648,7 +720,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
/// 检查某行是否落在 avoidPageBreakInside 保护块内
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@@ -665,6 +737,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 检查某行是否落在 keepWithNext 保护块内
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@@ -681,6 +754,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
@@ -690,6 +764,7 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
@@ -698,6 +773,9 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志,记录分页原因、范围和语义信息
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
// MARK: - NSAttributedString 分页扩展
|
||||
|
||||
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
|
||||
extension NSAttributedString {
|
||||
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
|
||||
/// - Parameters:
|
||||
/// - size: 页面尺寸
|
||||
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
|
||||
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
|
||||
/// - Returns: 分页后的布局帧列表
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
@@ -11,12 +22,17 @@ extension NSAttributedString {
|
||||
.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor CSS 转换扩展
|
||||
|
||||
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS rgba() 字符串格式
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Performance Sample
|
||||
// MARK: - 性能采样数据模型
|
||||
|
||||
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
|
||||
public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
/// 章节文件路径
|
||||
public var chapterHref: String
|
||||
/// HTML 渲染耗时(秒)
|
||||
public var renderDuration: TimeInterval
|
||||
/// CoreText 分页耗时(秒)
|
||||
public var paginateDuration: TimeInterval
|
||||
/// 分页后的页数
|
||||
public var pageCount: Int
|
||||
/// 富文本字符长度
|
||||
public var attributedStringLength: Int
|
||||
/// 是否命中分页缓存
|
||||
public var cacheHit: Bool
|
||||
|
||||
public init(
|
||||
@@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Sampler
|
||||
// MARK: - 性能采样器
|
||||
|
||||
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
|
||||
///
|
||||
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
|
||||
/// 构建完成后输出汇总报告。
|
||||
public final class RDEPUBTextPerformanceSampler {
|
||||
/// 所有章节的采样数据列表
|
||||
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 整本书构建的总耗时(秒)
|
||||
public var totalBuildDuration: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
public func summary() -> String {
|
||||
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
|
||||
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
|
||||
@@ -47,11 +62,13 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
|
||||
}
|
||||
|
||||
/// 重置所有采样数据
|
||||
public func reset() {
|
||||
samples.removeAll()
|
||||
totalBuildDuration = 0
|
||||
}
|
||||
|
||||
/// 将秒转换为毫秒格式字符串
|
||||
private func formatMS(_ duration: TimeInterval) -> String {
|
||||
String(format: "%.0fms", duration * 1000)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,71 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 自定义富文本属性键
|
||||
|
||||
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
|
||||
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
|
||||
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
|
||||
public extension NSAttributedString.Key {
|
||||
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
|
||||
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
|
||||
/// 块级元素在章节中的序号
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||
/// fragment 锚点 ID
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||
/// 附件类型(图片/通用附件)
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
||||
/// 块级元素类型(段落/列表/表格/代码等)
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
|
||||
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
|
||||
/// 附件布局方式(行内/基线/居中)
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
// MARK: - 块级元素类型枚举
|
||||
|
||||
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
case paragraph // <p>
|
||||
case list // <ul>, <ol>, <li>
|
||||
case table // <table> 系列
|
||||
case code // <pre>, <code>
|
||||
case blockquote // <blockquote>
|
||||
case attachment // 附件块(图片、figure 等)
|
||||
case generic // 通用块(<div>, <h1>-<h6> 等)
|
||||
}
|
||||
|
||||
// MARK: - 语义提示枚举
|
||||
|
||||
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
|
||||
case keepWithNext // 与下一段保持同页(如标题)
|
||||
case pageBreakBefore // 在此元素前强制分页
|
||||
case pageBreakAfter // 在此元素后强制分页
|
||||
case pageRelate // 微信读书式跨页关联元素
|
||||
}
|
||||
|
||||
// MARK: - 附件布局方式枚举
|
||||
|
||||
/// 富文本附件(图片等)的垂直布局方式。
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
case inline // 行内布局
|
||||
case baseline // 基线对齐
|
||||
case centered // 垂直居中(块级)
|
||||
}
|
||||
|
||||
// MARK: - 渲染样式
|
||||
|
||||
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
|
||||
public var font: UIFont
|
||||
/// 行间距(pt)
|
||||
public var lineSpacing: CGFloat
|
||||
/// 文本颜色(nil 则使用 EPUB 原始颜色)
|
||||
public var textColor: UIColor?
|
||||
/// 背景颜色(nil 则透明,用于暗色模式判断)
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
@@ -48,10 +76,17 @@ public struct RDEPUBTextRenderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 布局配置
|
||||
|
||||
/// 分页引擎的布局控制参数。
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
||||
public var avoidOrphans: Bool
|
||||
/// 是否避免寡行(段落第一行单独在上一页底部)
|
||||
public var avoidWidows: Bool
|
||||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
/// 图片最大高度占页面高度的比例
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
|
||||
public init(
|
||||
@@ -66,17 +101,22 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
}
|
||||
|
||||
/// 默认配置
|
||||
public static let `default` = RDEPUBTextLayoutConfig()
|
||||
}
|
||||
|
||||
// MARK: - CSS 样式表层级
|
||||
|
||||
/// 样式表层级类型,用于 CSS 层叠优先级管理。
|
||||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||||
case `default`
|
||||
case replace
|
||||
case dark
|
||||
case epub
|
||||
case user
|
||||
case `default` // 基础重置样式(margin、padding 等)
|
||||
case replace // 元素替换样式(图片居中、标题分页等)
|
||||
case dark // 暗色模式覆盖
|
||||
case epub // EPUB 原始样式表
|
||||
case user // 用户自定义样式(字号、行距、颜色等)
|
||||
}
|
||||
|
||||
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
|
||||
public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
public var kind: RDEPUBTextStyleSheetLayerKind
|
||||
public var css: String
|
||||
@@ -87,6 +127,9 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
|
||||
///
|
||||
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
|
||||
public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
public var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
@@ -94,6 +137,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
/// 合并所有非空层的 CSS,每层添加注释头标记
|
||||
public var combinedCSS: String {
|
||||
layers
|
||||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
@@ -104,11 +148,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 资源引用诊断
|
||||
|
||||
/// 资源引用类型(样式表或图片)
|
||||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||||
case stylesheet
|
||||
case image
|
||||
}
|
||||
|
||||
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
|
||||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
public var kind: RDEPUBTextResourceReferenceKind
|
||||
public var chapterHref: String
|
||||
@@ -134,6 +182,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节渲染上下文与请求
|
||||
|
||||
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
|
||||
public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
@@ -159,6 +210,7 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
|
||||
public struct RDEPUBTextChapterRenderRequest {
|
||||
public var context: RDEPUBTextChapterContext
|
||||
public var style: RDEPUBTextRenderStyle
|
||||
@@ -169,9 +221,15 @@ public struct RDEPUBTextChapterRenderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染结果
|
||||
|
||||
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
/// 渲染后的富文本
|
||||
public var attributedString: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射
|
||||
public var fragmentOffsets: [String: Int]
|
||||
/// 资源引用诊断列表
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public init(
|
||||
@@ -185,11 +243,18 @@ public struct RDEPUBRenderedChapterContent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染器协议
|
||||
|
||||
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
|
||||
///
|
||||
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
|
||||
public protocol RDEPUBTextRenderer {
|
||||
/// 渲染单个章节
|
||||
func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
|
||||
/// 便捷方法:直接渲染 HTML 字符串
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@@ -197,6 +262,7 @@ public protocol RDEPUBTextRenderer {
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
/// 协议默认实现:将便捷方法委托给完整方法
|
||||
public extension RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
@@ -215,9 +281,12 @@ public extension RDEPUBTextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染错误
|
||||
|
||||
/// EPUB 文本渲染过程中可能出现的错误类型。
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
|
||||
case htmlImportFailed // HTML 富文本解析失败
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@@ -4,13 +4,36 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 渲染器支持工具集:负责 HTML 预处理、fragment 标记注入/提取、CSS 组装和字体标准化。
|
||||
///
|
||||
/// 本枚举是渲染链路的预处理层,`RDEPUBTextBookBuilder` 在调用渲染器前通过
|
||||
/// `makeChapterRenderRequest()` 完成 HTML 的全部预处理工作。
|
||||
///
|
||||
/// 核心职责:
|
||||
/// - HTML 规范化:清理冗余字符、规范化附件标记
|
||||
/// - Fragment 标记注入/提取:id 属性 → ${id=xxx} 标记 → 字符偏移量映射
|
||||
/// - 语义标记注入:HTML 标签 → ${rd-sem-start/end} 标记 → NSAttributedString 属性
|
||||
/// - CSS 组装:按优先级合并 default/replace/dark/epub/user 五层样式表
|
||||
/// - 字体标准化:将 EPUB 原始字体映射到用户设置的字体
|
||||
/// - 附件规范化:图片尺寸、脚注图标、封面图片等特殊处理
|
||||
enum RDEPUBTextRendererSupport {
|
||||
/// 外部样式表链接正则
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 图片源地址正则
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
/// 脚注附件日志已输出标记(避免重复日志)
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
private static var didLogCoverAttachment = false
|
||||
|
||||
// MARK: - Fragment 标记注入/提取
|
||||
|
||||
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
|
||||
///
|
||||
/// 将 `<tag id="xxx" ...>` 转换为 `${id=xxx}<tag id="xxx" ...>`,
|
||||
/// 使 DTCoreText 渲染后能在富文本中定位到 fragment 偏移量。
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
@@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从渲染后的富文本中提取 fragment 偏移量映射。
|
||||
///
|
||||
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
|
||||
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
@@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
|
||||
///
|
||||
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
|
||||
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
@@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
///
|
||||
/// 遍历富文本的所有属性区间:
|
||||
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
|
||||
/// 2. 统一行距和段间距
|
||||
/// 3. 应用用户设置的文本颜色
|
||||
/// 4. 规范化附件显示尺寸
|
||||
/// 5. 注入块级元素范围、序号、类型等分页语义属性
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
@@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
@@ -137,6 +177,16 @@ enum RDEPUBTextRendererSupport {
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
|
||||
///
|
||||
/// 预处理流程:
|
||||
/// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记
|
||||
/// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记
|
||||
/// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中
|
||||
/// 4. 组装五层 CSS(default/replace/dark/epub/user)并注入 style 标签
|
||||
/// 5. injectBaseHref:注入 base 标签以解析相对路径
|
||||
/// 6. injectFragmentMarkers:注入 fragment 锚点标记
|
||||
/// 7. collectImageDiagnostics:收集图片资源引用诊断
|
||||
static func makeChapterRenderRequest(
|
||||
href: String,
|
||||
title: String,
|
||||
@@ -197,6 +247,7 @@ enum RDEPUBTextRendererSupport {
|
||||
return RDEPUBTextChapterRenderRequest(context: context, style: style)
|
||||
}
|
||||
|
||||
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
||||
static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
|
||||
///
|
||||
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
|
||||
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
|
||||
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
@@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 在全书中搜索关键词,返回所有匹配结果。
|
||||
///
|
||||
/// 搜索流程:
|
||||
/// 1. 遍历所有章节
|
||||
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
|
||||
/// 3. 计算匹配位置的阅读进度(progression)
|
||||
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
|
||||
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
return matches
|
||||
}
|
||||
|
||||
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import UIKit
|
||||
|
||||
/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线
|
||||
/// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。
|
||||
///
|
||||
/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型,
|
||||
/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。
|
||||
///
|
||||
/// 处理流程:
|
||||
/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码)
|
||||
/// 2. 按中文章节正则拆分为多个章节
|
||||
/// 3. 将纯文本包装为 HTML 段落
|
||||
/// 4. 通过渲染器(DTCoreText)渲染为富文本
|
||||
/// 5. CoreText 分页 → RDEPUBTextBook
|
||||
public final class RDPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
@@ -8,6 +18,13 @@ public final class RDPlainTextBookBuilder {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
/// 从纯文本文件构建分页书籍。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - textFileURL: 纯文本文件的本地 URL
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - style: 渲染样式(字体、行距、颜色)
|
||||
/// - Returns: 分页后的书籍模型
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
@@ -86,12 +103,14 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
/// 章节规格:标题 + 内容
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。无匹配时整体作为一章。
|
||||
/// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
|
||||
/// 无匹配时将整篇文本作为一章处理。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
@@ -126,7 +145,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落
|
||||
/// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
@@ -135,6 +154,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
|
||||
Reference in New Issue
Block a user