refactor: split reader architecture and chrome handling
This commit is contained in:
parent
ea21c6a831
commit
44202357c0
2878
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
2878
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -185,14 +185,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||
|
||||
@ -0,0 +1,350 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 选择发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 选中的文本内容
|
||||
public var text: String
|
||||
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
|
||||
public var rangeInfo: String?
|
||||
/// 选择创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 判断是否为空选择(无文本或无范围信息)
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮样式类型
|
||||
/// - highlight: 背景色高亮
|
||||
/// - underline: 下划线标记
|
||||
public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
|
||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||||
case bookmark
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
case highlight
|
||||
case annotate
|
||||
}
|
||||
|
||||
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
|
||||
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
|
||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
/// 类型标识符,固定为 "text-offset"
|
||||
public var kind: String
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 起始字符偏移量
|
||||
public var start: Int
|
||||
/// 结束字符偏移量
|
||||
public var end: Int
|
||||
|
||||
/// 初始化文本偏移范围
|
||||
/// - Parameters:
|
||||
/// - href: 资源路径
|
||||
/// - start: 起始字符偏移量
|
||||
/// - end: 结束字符偏移量
|
||||
public init(href: String, start: Int, end: Int) {
|
||||
self.kind = "text-offset"
|
||||
self.href = href
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(用于 NSAttributedString 操作)
|
||||
public var nsRange: NSRange? {
|
||||
guard end > start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
/// 序列化为 JSON 字符串
|
||||
public func jsonString() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
|
||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
||||
guard let string,
|
||||
let data = string.data(using: .utf8),
|
||||
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
||||
rangeInfo.kind == "text-offset",
|
||||
rangeInfo.end > rangeInfo.start else {
|
||||
return nil
|
||||
}
|
||||
return rangeInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分页截断原因,描述为什么在当前位置分页
|
||||
public struct RDEPUBHighlight: Codable, Equatable {
|
||||
/// 高亮唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 高亮发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 高亮的文本内容
|
||||
public var text: String
|
||||
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
|
||||
public var rangeInfo: String?
|
||||
/// 高亮样式(背景色高亮或下划线)
|
||||
public var style: RDEPUBHighlightStyle
|
||||
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
|
||||
public var color: String
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 编码键,用于 Codable 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
case location
|
||||
case text
|
||||
case rangeInfo
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case createdAt
|
||||
}
|
||||
|
||||
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
||||
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
/// 自定义编码器
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
||||
try container.encode(location, forKey: .location)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(highlight: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
public struct RDEPUBBookmark: Codable, Equatable {
|
||||
/// 书签唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 书签位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 章节标题(用于书签列表展示)
|
||||
public var chapterTitle: String?
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var kind: RDEPUBAnnotationKind
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String?
|
||||
public var rangeInfo: String?
|
||||
public var color: String?
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
kind: RDEPUBAnnotationKind,
|
||||
location: RDEPUBLocation,
|
||||
text: String? = nil,
|
||||
rangeInfo: String? = nil,
|
||||
color: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.kind = kind
|
||||
self.location = location
|
||||
self.text = text?.nilIfEmpty
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.color = color?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public init(highlight: RDEPUBHighlight) {
|
||||
self.init(
|
||||
id: highlight.id,
|
||||
bookIdentifier: highlight.bookIdentifier,
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
location: highlight.location,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
color: highlight.color,
|
||||
chapterTitle: nil,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public init(bookmark: RDEPUBBookmark) {
|
||||
self.init(
|
||||
id: bookmark.id,
|
||||
bookIdentifier: bookmark.bookIdentifier,
|
||||
kind: .bookmark,
|
||||
location: bookmark.location,
|
||||
text: nil,
|
||||
rangeInfo: nil,
|
||||
color: nil,
|
||||
chapterTitle: bookmark.chapterTitle,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var bookmark: RDEPUBBookmark? {
|
||||
guard kind == .bookmark else { return nil }
|
||||
return RDEPUBBookmark(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
chapterTitle: chapterTitle,
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var highlight: RDEPUBHighlight? {
|
||||
guard kind == .highlight || kind == .underline,
|
||||
let text else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBHighlight(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo,
|
||||
style: kind == .underline ? .underline : .highlight,
|
||||
color: color ?? "#F8E16C",
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,229 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
||||
/// 章节自然结束
|
||||
case chapterEnd
|
||||
/// 达到帧容量限制
|
||||
case frameLimit
|
||||
/// 块级元素边界(如段落、标题)
|
||||
case blockBoundary
|
||||
/// 附件(图片等)边界
|
||||
case attachmentBoundary
|
||||
/// 语义边界(如列表、引用块)
|
||||
case semanticBoundary
|
||||
}
|
||||
|
||||
/// 文本附件类型
|
||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
/// 图片附件
|
||||
case image
|
||||
/// 通用附件
|
||||
case generic
|
||||
}
|
||||
|
||||
/// 文本页元数据,记录分页时的上下文信息
|
||||
/// 用于调试分页问题和优化分页质量
|
||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
/// 分页截断原因
|
||||
public var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 本页覆盖的块级元素范围
|
||||
public var blockRange: NSRange?
|
||||
/// 本页包含的附件范围列表
|
||||
public var attachmentRanges: [NSRange]
|
||||
/// 附件类型列表
|
||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 块级元素类型列表
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 语义提示信息
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 附件布局位置信息
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 本页末尾的 fragment ID(用于恢复位置)
|
||||
public var trailingFragmentID: String?
|
||||
/// 诊断信息列表(调试用)
|
||||
public var diagnostics: [String]
|
||||
|
||||
public init(
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange? = nil,
|
||||
attachmentRanges: [NSRange] = [],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
||||
blockKinds: [RDEPUBTextBlockKind] = [],
|
||||
semanticHints: [RDEPUBTextSemanticHint] = [],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
||||
trailingFragmentID: String? = nil,
|
||||
diagnostics: [String] = []
|
||||
) {
|
||||
self.breakReason = breakReason
|
||||
self.blockRange = blockRange
|
||||
self.attachmentRanges = attachmentRanges
|
||||
self.attachmentKinds = attachmentKinds
|
||||
self.blockKinds = blockKinds
|
||||
self.semanticHints = semanticHints
|
||||
self.attachmentPlacements = attachmentPlacements
|
||||
self.trailingFragmentID = trailingFragmentID
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮模型,表示用户在阅读中标记的文本片段
|
||||
public struct EPUBChapterInfo: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节标题
|
||||
public var title: String
|
||||
/// 该章节的总页数
|
||||
public var pageCount: Int
|
||||
|
||||
public init(spineIndex: Int, title: String, pageCount: Int) {
|
||||
self.spineIndex = spineIndex
|
||||
self.title = title
|
||||
self.pageCount = pageCount
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面模型,描述单页的完整信息
|
||||
/// 由 RDEPUBReadingSession 根据分页结果构建,是翻页容器层的数据单元
|
||||
public struct EPUBPage: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节索引(从 0 开始的章节序号)
|
||||
public var chapterIndex: Int
|
||||
/// 页在章节中的索引(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
/// 该章节的总页数
|
||||
public var totalPagesInChapter: Int
|
||||
/// 章节标题
|
||||
public var chapterTitle: String
|
||||
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
|
||||
public var fixedSpread: EPUBFixedSpread?
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int,
|
||||
pageIndexInChapter: Int,
|
||||
totalPagesInChapter: Int,
|
||||
chapterTitle: String,
|
||||
fixedSpread: EPUBFixedSpread?
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterIndex = chapterIndex
|
||||
self.pageIndexInChapter = pageIndexInChapter
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.fixedSpread = fixedSpread
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 Spread 中的单个资源
|
||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 资源标题
|
||||
public var title: String
|
||||
/// 在 spread 中的位置偏好
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.pageSpread = pageSpread
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 Spread(双页展开)模型
|
||||
/// 将 1-2 个 spine 资源组合为一个显示单元
|
||||
public struct EPUBFixedSpread: Codable, Equatable {
|
||||
/// 该 spread 包含的资源列表(通常 1-2 个)
|
||||
public var resources: [EPUBFixedSpreadResource]
|
||||
|
||||
public init(resources: [EPUBFixedSpreadResource]) {
|
||||
self.resources = resources
|
||||
}
|
||||
|
||||
/// 主资源(列表中的第一个),用于标识和定位
|
||||
public var primaryResource: EPUBFixedSpreadResource {
|
||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
||||
}
|
||||
|
||||
/// 判断指定 href 的资源是否属于此 spread
|
||||
/// - Parameters:
|
||||
/// - normalizedHref: 标准化后的 href
|
||||
/// - normalizer: href 标准化函数
|
||||
/// - Returns: 是否包含该资源
|
||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
||||
resources.contains { resource in
|
||||
normalizer(resource.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 spine 列表和 spread 设置生成 Spread 数组
|
||||
/// 根据 pageSpread 属性(left/right/center)决定资源配对
|
||||
/// - Parameters:
|
||||
/// - spine: spine 项列表
|
||||
/// - spreadEnabled: 是否启用 spread 模式
|
||||
/// - Returns: spread 数组
|
||||
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
let resources = spine.enumerated().map { index, item in
|
||||
EPUBFixedSpreadResource(
|
||||
spineIndex: index,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageSpread: item.pageSpread
|
||||
)
|
||||
}
|
||||
|
||||
guard spreadEnabled, resources.count > 1 else {
|
||||
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
||||
}
|
||||
|
||||
var spreads: [EPUBFixedSpread] = []
|
||||
var cursor = 0
|
||||
|
||||
while cursor < resources.count {
|
||||
let current = resources[cursor]
|
||||
|
||||
guard cursor + 1 < resources.count else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
break
|
||||
}
|
||||
|
||||
let next = resources[cursor + 1]
|
||||
if shouldPair(current: current, next: next) {
|
||||
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
||||
cursor += 2
|
||||
} else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
return spreads
|
||||
}
|
||||
|
||||
/// 便捷方法:从 parser 构建 spread
|
||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
||||
}
|
||||
|
||||
/// 判断两个资源是否应该配对为一个 spread
|
||||
/// center 类型不参与配对,left+right 或 right+left 配对
|
||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
||||
if current.pageSpread == .center || next.pageSpread == .center {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (current.pageSpread, next.pageSpread) {
|
||||
case (.left, .right), (.right, .left):
|
||||
return true
|
||||
case (.left, .left), (.right, .right):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBLocation: Codable, Equatable {
|
||||
/// 书籍唯一标识符,用于隔离不同书的阅读进度
|
||||
public var bookIdentifier: String?
|
||||
/// OPF 相对路径,对应 spine 中的资源文件
|
||||
public var href: String
|
||||
/// 视口起始位置在章节中的相对进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符(如 #section1),用于精确跳转
|
||||
public var fragment: String?
|
||||
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
|
||||
/// 用于在字号/横竖屏变化后估算最接近的页号
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
return progression
|
||||
}
|
||||
return Self.clamp((progression + end) / 2.0)
|
||||
}
|
||||
|
||||
/// 将进度值限制在 [0, 1] 范围内,防止越界
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(1, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
|
||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
/// 资源路径(相对于 OPF 目录)
|
||||
public var href: String
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 该资源在视口中的起始进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 该资源在视口中的结束进度 [0, 1]
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.spineIndex = spineIndex
|
||||
self.progression = progression
|
||||
self.lastProgression = lastProgression
|
||||
self.fragment = fragment
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口模型,描述当前屏幕可见区域的完整状态
|
||||
/// 可能跨越多个 spine 资源(如分栏布局时)
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
/// 当前可见的页码
|
||||
public var visiblePageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
/// 是否为固定版式内容
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
resources: [RDEPUBViewportResource],
|
||||
visiblePageNumber: Int,
|
||||
chapterIndex: Int? = nil,
|
||||
isFixedLayout: Bool
|
||||
) {
|
||||
self.resources = resources
|
||||
self.visiblePageNumber = visiblePageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
self.isFixedLayout = isFixedLayout
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
|
||||
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
|
||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
/// 当前阅读位置(href + progression)
|
||||
public var location: RDEPUBLocation
|
||||
/// 当前视口状态
|
||||
public var viewport: RDEPUBViewport
|
||||
/// 当前扁平页码(跨章节累计)
|
||||
public var pageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public init(
|
||||
location: RDEPUBLocation,
|
||||
viewport: RDEPUBViewport,
|
||||
pageNumber: Int,
|
||||
chapterIndex: Int? = nil
|
||||
) {
|
||||
self.location = location
|
||||
self.viewport = viewport
|
||||
self.pageNumber = pageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -1,719 +0,0 @@
|
||||
//
|
||||
// RDEPUBReadingModels.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层阅读会话相关数据模型定义文件。
|
||||
// 包含阅读位置(Location)、视口(Viewport)、阅读上下文(ReadingContext)、
|
||||
// 文本选择(Selection)、高亮(Highlight)、书签(Bookmark)、
|
||||
// 章节信息(ChapterInfo)、页面模型(Page)、固定版式 Spread 模型等。
|
||||
// 这些模型由 RDEPUBReadingSession 管理,贯穿整个阅读生命周期。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 阅读位置模型,EPUB 是可重排内容,使用 href + progression 而非页号来定位
|
||||
/// progression 取值 [0, 1],表示当前视口在章节中的相对位置
|
||||
public struct RDEPUBLocation: Codable, Equatable {
|
||||
/// 书籍唯一标识符,用于隔离不同书的阅读进度
|
||||
public var bookIdentifier: String?
|
||||
/// OPF 相对路径,对应 spine 中的资源文件
|
||||
public var href: String
|
||||
/// 视口起始位置在章节中的相对进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符(如 #section1),用于精确跳转
|
||||
public var fragment: String?
|
||||
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
|
||||
/// 用于在字号/横竖屏变化后估算最接近的页号
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
return progression
|
||||
}
|
||||
return Self.clamp((progression + end) / 2.0)
|
||||
}
|
||||
|
||||
/// 将进度值限制在 [0, 1] 范围内,防止越界
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(1, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
|
||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
/// 资源路径(相对于 OPF 目录)
|
||||
public var href: String
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 该资源在视口中的起始进度 [0, 1]
|
||||
public var progression: Double
|
||||
/// 该资源在视口中的结束进度 [0, 1]
|
||||
public var lastProgression: Double?
|
||||
/// 锚点标识符
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.spineIndex = spineIndex
|
||||
self.progression = progression
|
||||
self.lastProgression = lastProgression
|
||||
self.fragment = fragment
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口模型,描述当前屏幕可见区域的完整状态
|
||||
/// 可能跨越多个 spine 资源(如分栏布局时)
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
/// 当前可见的页码
|
||||
public var visiblePageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
/// 是否为固定版式内容
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
resources: [RDEPUBViewportResource],
|
||||
visiblePageNumber: Int,
|
||||
chapterIndex: Int? = nil,
|
||||
isFixedLayout: Bool
|
||||
) {
|
||||
self.resources = resources
|
||||
self.visiblePageNumber = visiblePageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
self.isFixedLayout = isFixedLayout
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
|
||||
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
|
||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
/// 当前阅读位置(href + progression)
|
||||
public var location: RDEPUBLocation
|
||||
/// 当前视口状态
|
||||
public var viewport: RDEPUBViewport
|
||||
/// 当前扁平页码(跨章节累计)
|
||||
public var pageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public init(
|
||||
location: RDEPUBLocation,
|
||||
viewport: RDEPUBViewport,
|
||||
pageNumber: Int,
|
||||
chapterIndex: Int? = nil
|
||||
) {
|
||||
self.location = location
|
||||
self.viewport = viewport
|
||||
self.pageNumber = pageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 选择发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 选中的文本内容
|
||||
public var text: String
|
||||
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
|
||||
public var rangeInfo: String?
|
||||
/// 选择创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 判断是否为空选择(无文本或无范围信息)
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮样式类型
|
||||
/// - highlight: 背景色高亮
|
||||
/// - underline: 下划线标记
|
||||
public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
|
||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||||
case bookmark
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
case highlight
|
||||
case annotate
|
||||
}
|
||||
|
||||
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
|
||||
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
|
||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
/// 类型标识符,固定为 "text-offset"
|
||||
public var kind: String
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 起始字符偏移量
|
||||
public var start: Int
|
||||
/// 结束字符偏移量
|
||||
public var end: Int
|
||||
|
||||
/// 初始化文本偏移范围
|
||||
/// - Parameters:
|
||||
/// - href: 资源路径
|
||||
/// - start: 起始字符偏移量
|
||||
/// - end: 结束字符偏移量
|
||||
public init(href: String, start: Int, end: Int) {
|
||||
self.kind = "text-offset"
|
||||
self.href = href
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(用于 NSAttributedString 操作)
|
||||
public var nsRange: NSRange? {
|
||||
guard end > start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
/// 序列化为 JSON 字符串
|
||||
public func jsonString() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
|
||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
||||
guard let string,
|
||||
let data = string.data(using: .utf8),
|
||||
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
||||
rangeInfo.kind == "text-offset",
|
||||
rangeInfo.end > rangeInfo.start else {
|
||||
return nil
|
||||
}
|
||||
return rangeInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分页截断原因,描述为什么在当前位置分页
|
||||
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
||||
/// 章节自然结束
|
||||
case chapterEnd
|
||||
/// 达到帧容量限制
|
||||
case frameLimit
|
||||
/// 块级元素边界(如段落、标题)
|
||||
case blockBoundary
|
||||
/// 附件(图片等)边界
|
||||
case attachmentBoundary
|
||||
/// 语义边界(如列表、引用块)
|
||||
case semanticBoundary
|
||||
}
|
||||
|
||||
/// 文本附件类型
|
||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
||||
/// 图片附件
|
||||
case image
|
||||
/// 通用附件
|
||||
case generic
|
||||
}
|
||||
|
||||
/// 文本页元数据,记录分页时的上下文信息
|
||||
/// 用于调试分页问题和优化分页质量
|
||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
/// 分页截断原因
|
||||
public var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 本页覆盖的块级元素范围
|
||||
public var blockRange: NSRange?
|
||||
/// 本页包含的附件范围列表
|
||||
public var attachmentRanges: [NSRange]
|
||||
/// 附件类型列表
|
||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 块级元素类型列表
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 语义提示信息
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 附件布局位置信息
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 本页末尾的 fragment ID(用于恢复位置)
|
||||
public var trailingFragmentID: String?
|
||||
/// 诊断信息列表(调试用)
|
||||
public var diagnostics: [String]
|
||||
|
||||
public init(
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange? = nil,
|
||||
attachmentRanges: [NSRange] = [],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
||||
blockKinds: [RDEPUBTextBlockKind] = [],
|
||||
semanticHints: [RDEPUBTextSemanticHint] = [],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
||||
trailingFragmentID: String? = nil,
|
||||
diagnostics: [String] = []
|
||||
) {
|
||||
self.breakReason = breakReason
|
||||
self.blockRange = blockRange
|
||||
self.attachmentRanges = attachmentRanges
|
||||
self.attachmentKinds = attachmentKinds
|
||||
self.blockKinds = blockKinds
|
||||
self.semanticHints = semanticHints
|
||||
self.attachmentPlacements = attachmentPlacements
|
||||
self.trailingFragmentID = trailingFragmentID
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮模型,表示用户在阅读中标记的文本片段
|
||||
/// 支持 Web EPUB(DOM Range 定位)和文本 EPUB(字符偏移定位)两种模式
|
||||
public struct RDEPUBHighlight: Codable, Equatable {
|
||||
/// 高亮唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 高亮发生的位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 高亮的文本内容
|
||||
public var text: String
|
||||
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
|
||||
public var rangeInfo: String?
|
||||
/// 高亮样式(背景色高亮或下划线)
|
||||
public var style: RDEPUBHighlightStyle
|
||||
/// 高亮颜色(CSS 颜色值,如 "#F8E16C")
|
||||
public var color: String
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 编码键,用于 Codable 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
case location
|
||||
case text
|
||||
case rangeInfo
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case createdAt
|
||||
}
|
||||
|
||||
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
||||
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
/// 自定义编码器
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
||||
try container.encode(location, forKey: .location)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(highlight: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
public struct RDEPUBBookmark: Codable, Equatable {
|
||||
/// 书签唯一标识符
|
||||
public var id: String
|
||||
/// 书籍标识符
|
||||
public var bookIdentifier: String?
|
||||
/// 书签位置
|
||||
public var location: RDEPUBLocation
|
||||
/// 章节标题(用于书签列表展示)
|
||||
public var chapterTitle: String?
|
||||
/// 用户备注
|
||||
public var note: String?
|
||||
/// 创建时间
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var kind: RDEPUBAnnotationKind
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String?
|
||||
public var rangeInfo: String?
|
||||
public var color: String?
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
kind: RDEPUBAnnotationKind,
|
||||
location: RDEPUBLocation,
|
||||
text: String? = nil,
|
||||
rangeInfo: String? = nil,
|
||||
color: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.kind = kind
|
||||
self.location = location
|
||||
self.text = text?.nilIfEmpty
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.color = color?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public init(highlight: RDEPUBHighlight) {
|
||||
self.init(
|
||||
id: highlight.id,
|
||||
bookIdentifier: highlight.bookIdentifier,
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
location: highlight.location,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
color: highlight.color,
|
||||
chapterTitle: nil,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public init(bookmark: RDEPUBBookmark) {
|
||||
self.init(
|
||||
id: bookmark.id,
|
||||
bookIdentifier: bookmark.bookIdentifier,
|
||||
kind: .bookmark,
|
||||
location: bookmark.location,
|
||||
text: nil,
|
||||
rangeInfo: nil,
|
||||
color: nil,
|
||||
chapterTitle: bookmark.chapterTitle,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var bookmark: RDEPUBBookmark? {
|
||||
guard kind == .bookmark else { return nil }
|
||||
return RDEPUBBookmark(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
chapterTitle: chapterTitle,
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var highlight: RDEPUBHighlight? {
|
||||
guard kind == .highlight || kind == .underline,
|
||||
let text else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBHighlight(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo,
|
||||
style: kind == .underline ? .underline : .highlight,
|
||||
color: color ?? "#F8E16C",
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节信息模型,描述单个章节的元数据
|
||||
public struct EPUBChapterInfo: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节标题
|
||||
public var title: String
|
||||
/// 该章节的总页数
|
||||
public var pageCount: Int
|
||||
|
||||
public init(spineIndex: Int, title: String, pageCount: Int) {
|
||||
self.spineIndex = spineIndex
|
||||
self.title = title
|
||||
self.pageCount = pageCount
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面模型,描述单页的完整信息
|
||||
/// 由 RDEPUBReadingSession 根据分页结果构建,是翻页容器层的数据单元
|
||||
public struct EPUBPage: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 章节索引(从 0 开始的章节序号)
|
||||
public var chapterIndex: Int
|
||||
/// 页在章节中的索引(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
/// 该章节的总页数
|
||||
public var totalPagesInChapter: Int
|
||||
/// 章节标题
|
||||
public var chapterTitle: String
|
||||
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
|
||||
public var fixedSpread: EPUBFixedSpread?
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int,
|
||||
pageIndexInChapter: Int,
|
||||
totalPagesInChapter: Int,
|
||||
chapterTitle: String,
|
||||
fixedSpread: EPUBFixedSpread?
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterIndex = chapterIndex
|
||||
self.pageIndexInChapter = pageIndexInChapter
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.fixedSpread = fixedSpread
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 Spread 中的单个资源
|
||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
/// 对应的 spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 资源标题
|
||||
public var title: String
|
||||
/// 在 spread 中的位置偏好
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.pageSpread = pageSpread
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 Spread(双页展开)模型
|
||||
/// 将 1-2 个 spine 资源组合为一个显示单元
|
||||
public struct EPUBFixedSpread: Codable, Equatable {
|
||||
/// 该 spread 包含的资源列表(通常 1-2 个)
|
||||
public var resources: [EPUBFixedSpreadResource]
|
||||
|
||||
public init(resources: [EPUBFixedSpreadResource]) {
|
||||
self.resources = resources
|
||||
}
|
||||
|
||||
/// 主资源(列表中的第一个),用于标识和定位
|
||||
public var primaryResource: EPUBFixedSpreadResource {
|
||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
||||
}
|
||||
|
||||
/// 判断指定 href 的资源是否属于此 spread
|
||||
/// - Parameters:
|
||||
/// - normalizedHref: 标准化后的 href
|
||||
/// - normalizer: href 标准化函数
|
||||
/// - Returns: 是否包含该资源
|
||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
||||
resources.contains { resource in
|
||||
normalizer(resource.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 spine 列表和 spread 设置生成 Spread 数组
|
||||
/// 根据 pageSpread 属性(left/right/center)决定资源配对
|
||||
/// - Parameters:
|
||||
/// - spine: spine 项列表
|
||||
/// - spreadEnabled: 是否启用 spread 模式
|
||||
/// - Returns: spread 数组
|
||||
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
let resources = spine.enumerated().map { index, item in
|
||||
EPUBFixedSpreadResource(
|
||||
spineIndex: index,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageSpread: item.pageSpread
|
||||
)
|
||||
}
|
||||
|
||||
guard spreadEnabled, resources.count > 1 else {
|
||||
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
||||
}
|
||||
|
||||
var spreads: [EPUBFixedSpread] = []
|
||||
var cursor = 0
|
||||
|
||||
while cursor < resources.count {
|
||||
let current = resources[cursor]
|
||||
|
||||
guard cursor + 1 < resources.count else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
break
|
||||
}
|
||||
|
||||
let next = resources[cursor + 1]
|
||||
if shouldPair(current: current, next: next) {
|
||||
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
||||
cursor += 2
|
||||
} else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
return spreads
|
||||
}
|
||||
|
||||
/// 便捷方法:从 parser 构建 spread
|
||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
||||
}
|
||||
|
||||
/// 判断两个资源是否应该配对为一个 spread
|
||||
/// center 类型不参与配对,left+right 或 right+left 配对
|
||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
||||
if current.pageSpread == .center || next.pageSpread == .center {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (current.pageSpread, next.pageSpread) {
|
||||
case (.left, .right), (.right, .left):
|
||||
return true
|
||||
case (.left, .left), (.right, .right):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// String 扩展:空字符串转为 nil,用于 Codable 容错处理
|
||||
private extension String {
|
||||
/// 空白字符修剪后如果是空字符串则返回 nil
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds diagnostics and human-readable summaries for a text book build.
|
||||
struct RDEPUBBuildDiagnosticsReporter {
|
||||
func phase7SemanticSummary(
|
||||
title: String?,
|
||||
diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
|
||||
) -> String? {
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
|
||||
let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
|
||||
let note = diagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
|
||||
|
||||
var parts = [
|
||||
title,
|
||||
"章节 \(diagnostics.count)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
func chapterDiagnostic(
|
||||
href: String,
|
||||
title: String,
|
||||
pages: [RDEPUBTextPage]
|
||||
) -> RDEPUBTextChapterPaginationDiagnostic {
|
||||
RDEPUBTextChapterPaginationDiagnostic(
|
||||
href: href,
|
||||
title: title,
|
||||
pageCount: pages.count,
|
||||
breakReasons: pages.map(\.metadata.breakReason),
|
||||
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
|
||||
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
|
||||
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
|
||||
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
|
||||
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
|
||||
sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
|
||||
)
|
||||
}
|
||||
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
|
||||
/// Normalizes suspicious trailing or whitespace-only page frames after pagination.
|
||||
struct RDEPUBChapterTailNormalizer {
|
||||
func normalize(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
href: String
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
guard frames.count > 1 else { return frames }
|
||||
|
||||
var normalized = frames
|
||||
|
||||
var compacted: [RDEPUBTextLayoutFrame] = []
|
||||
compacted.reserveCapacity(normalized.count)
|
||||
for frame in normalized {
|
||||
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
|
||||
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
|
||||
if var previous = compacted.popLast() {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
compacted.append(frame)
|
||||
}
|
||||
normalized = compacted
|
||||
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
||||
if var previousFrame = normalized.popLast() {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||
}
|
||||
}
|
||||
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
|
||||
normalized.removeLast(2)
|
||||
normalized.append(mergedFrame)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func shouldDropWhitespaceOnlyFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard frame.contentRange.length > 0,
|
||||
attachmentCount(in: content, range: frame.contentRange) == 0 else {
|
||||
return false
|
||||
}
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard trailingFrame.contentRange.length > 0,
|
||||
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
||||
return false
|
||||
}
|
||||
|
||||
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
|
||||
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
|
||||
guard visibleCount <= 2,
|
||||
trailingFrame.contentRange.length <= 2,
|
||||
visibleCount > 0 || trailingAttachmentCount > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
) -> RDEPUBTextLayoutFrame {
|
||||
let mergedRange = NSRange(
|
||||
location: previousFrame.contentRange.location,
|
||||
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
||||
)
|
||||
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: mergedRange,
|
||||
breakReason: trailingFrame.breakReason,
|
||||
blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
|
||||
attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
|
||||
attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
|
||||
blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
|
||||
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
|
||||
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
|
||||
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
|
||||
diagnostics: previousFrame.diagnostics
|
||||
+ trailingFrame.diagnostics
|
||||
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
|
||||
)
|
||||
}
|
||||
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
) -> Int {
|
||||
guard range.length > 0 else { return 0 }
|
||||
let string = content.attributedSubstring(from: range).string
|
||||
let filteredScalars = string.unicodeScalars.filter { scalar in
|
||||
!CharacterSet.whitespacesAndNewlines.contains(scalar)
|
||||
&& !CharacterSet.controlCharacters.contains(scalar)
|
||||
}
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
content.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
||||
if value != nil {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import UIKit
|
||||
|
||||
/// Keeps pagination cache key generation and cache IO in one BuildPipeline role.
|
||||
struct RDEPUBPaginationCacheCoordinator {
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
|
||||
self.cache = cache
|
||||
self.layoutConfig = layoutConfig
|
||||
}
|
||||
|
||||
func cacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> String? {
|
||||
guard let cache else { return nil }
|
||||
return cache.cacheKey(
|
||||
bookID: bookID,
|
||||
fontSize: style.font.pointSize,
|
||||
lineHeightMultiple: style.lineSpacing,
|
||||
contentInsets: layoutConfig.edgeInsets,
|
||||
pageSize: pageSize,
|
||||
layoutConfigSignature: layoutConfig.cacheSignature
|
||||
)
|
||||
}
|
||||
|
||||
func load(key: String?) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
key.flatMap { cache?.load(key: $0) }
|
||||
}
|
||||
|
||||
func save(chapters: [RDEPUBTextChapter], key: String?) {
|
||||
guard let key else { return }
|
||||
let paginationCache = chapters.map { chapter in
|
||||
RDEPUBTextChapterPaginationCache(
|
||||
href: chapter.href,
|
||||
pageRanges: chapter.pages.map(\.contentRange),
|
||||
breakReasons: chapter.pages.map(\.metadata.breakReason),
|
||||
semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
|
||||
)
|
||||
}
|
||||
cache?.save(paginationCache, key: key)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,395 @@
|
||||
import UIKit
|
||||
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)
|
||||
|
||||
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)…"
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 章节分页诊断数据
|
||||
|
||||
/// 单个章节的分页诊断信息,用于调试和质量检测。
|
||||
/// 记录页数、分页原因、附件/块级元素统计等。
|
||||
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var pageCount: Int
|
||||
/// 每页的分页原因列表
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
/// 包含附件的页数
|
||||
public var attachmentPageCount: Int
|
||||
/// 因块级/附件边界调整而分页的页数
|
||||
public var blockAdjustedPageCount: Int
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 采样诊断日志(最多 4 条)
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
///
|
||||
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
|
||||
/// `contentRange` 标记了该页在章节富文本中的字符范围。
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
/// 全书绝对页码(从 0 开始)
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
/// 该页在所属章节中的相对页码(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
/// 所属章节的完整富文本内容(用于跨页查询)
|
||||
public var chapterContent: NSAttributedString
|
||||
/// 该页的富文本片段
|
||||
public var content: NSAttributedString
|
||||
/// 该页在章节富文本中的范围
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
/// 页的元数据(分页原因、附件、语义标记等)
|
||||
public var metadata: RDEPUBTextPageMetadata
|
||||
}
|
||||
|
||||
// MARK: - 章节数据模型
|
||||
|
||||
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
/// 章节完整富文本内容
|
||||
public var attributedContent: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍模型
|
||||
|
||||
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
|
||||
///
|
||||
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
|
||||
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
|
||||
public var positionConverter: RDEPUBTextPositionConverter {
|
||||
RDEPUBTextPositionConverter(book: self)
|
||||
}
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
self.pages = pages
|
||||
self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
|
||||
}
|
||||
|
||||
public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
/// 按 href 获取章节的数据访问层(包含索引表)
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按 spine 索引获取章节的数据访问层。
|
||||
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按绝对页码(从 1 开始)获取章节的数据访问层。
|
||||
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
|
||||
guard let page = page(at: pageNumber) else { return nil }
|
||||
return chapterData(forSpineIndex: page.spineIndex)
|
||||
}
|
||||
|
||||
/// 根据持久化位置解析所属章节的数据访问层。
|
||||
public func chapterData(
|
||||
for location: RDEPUBLocation,
|
||||
resolver: RDEPUBResourceResolver,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBChapterData? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: chapter.spineIndex,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
/// 根据持久化位置计算对应页码(从 1 开始)。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor 锚点定位
|
||||
/// 2. fragment 片段 ID
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
if let anchor = indexTable.anchor(for: normalizedLocation),
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let chapterData = chapterData(forPageNumber: pageNumber),
|
||||
let page = page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍构建器
|
||||
|
||||
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
|
||||
///
|
||||
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
|
||||
///
|
||||
/// 特性:
|
||||
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
/// - 性能采样(记录每章渲染/分页耗时)
|
||||
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
|
||||
@ -0,0 +1,46 @@
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBTextBookBuilding {
|
||||
func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook
|
||||
}
|
||||
|
||||
struct RDEPUBChapterRenderPipeline {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
|
||||
try renderer.renderChapter(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBChapterPaginationPipeline {
|
||||
private let frameFactory: RDEPUBPageFrameBuilding
|
||||
|
||||
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
|
||||
self.frameFactory = frameFactory
|
||||
}
|
||||
|
||||
func frames(
|
||||
for content: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
frameFactory.makeFrames(
|
||||
attributedString: content,
|
||||
pageSize: pageSize,
|
||||
config: config,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBTextBookBuilder: RDEPUBTextBookBuilding {}
|
||||
@ -0,0 +1,270 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 章节分页计数器:顶层编排循环,将富文本按页面尺寸拆分为多帧。
|
||||
///
|
||||
/// 分页策略优先级(从高到低):
|
||||
/// 1. avoidPageBreakInside — 不在保护块内分页(WXRead 行级回退扫描)
|
||||
/// 2. keepWithNext — 标题等元素需与下一段同页
|
||||
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
|
||||
/// 4. pageRelate — 微信读书式的跨页关联元素
|
||||
/// 5. 附件边界 — 块级附件应整体移到下一页
|
||||
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
|
||||
struct RDEPUBChapterPageCounter {
|
||||
private let factory: RDEPUBCoreTextPageFrameFactory
|
||||
private let attributedString: NSAttributedString
|
||||
private let pageSize: CGSize
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
init(factory: RDEPUBCoreTextPageFrameFactory) {
|
||||
self.factory = factory
|
||||
self.attributedString = factory.attributedString
|
||||
self.pageSize = factory.pageSize
|
||||
self.config = factory.config
|
||||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
|
||||
self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
|
||||
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - CoreText 分页路径(回退方案)
|
||||
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
||||
|
||||
guard usableWidth > 0, usableHeight > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = CGMutablePath()
|
||||
let pageRect = CGRect(
|
||||
x: config.edgeInsets.left,
|
||||
y: config.edgeInsets.bottom,
|
||||
width: usableWidth,
|
||||
height: usableHeight
|
||||
)
|
||||
framePath.addRect(pageRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
||||
from: frame,
|
||||
start: location,
|
||||
usableHeight: usableHeight,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
|
||||
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: avoidAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: adjusted.range,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: adjusted.blockRange,
|
||||
attachmentRanges: adjusted.attachmentRanges,
|
||||
attachmentKinds: adjusted.attachmentKinds,
|
||||
blockKinds: adjusted.blockKinds,
|
||||
semanticHints: adjusted.semanticHints,
|
||||
attachmentPlacements: adjusted.attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: adjusted.diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||
guard nextLocation > location else {
|
||||
location += max(proposedRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard config.numberOfColumns == 1 else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = dtLayoutRect
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
break
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
if adjusted.breakReason == .attachmentBoundary {
|
||||
verifiedRange = verifiedDisplayRange(for: adjusted.range)
|
||||
} else {
|
||||
verifiedRange = adjusted.range
|
||||
}
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
endingAt: verifiedRange.location + verifiedRange.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
let diagnostics = verifiedRange == adjusted.range
|
||||
? adjusted.diagnostics
|
||||
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
|
||||
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: verifiedRange,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
|
||||
attachmentRanges: factory.attachmentRanges(in: verifiedRange),
|
||||
attachmentKinds: factory.attachmentKinds(in: verifiedRange),
|
||||
blockKinds: factory.blockKinds(in: verifiedRange),
|
||||
semanticHints: factory.semanticHints(in: verifiedRange),
|
||||
attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = verifiedRange.location + verifiedRange.length
|
||||
guard nextLocation > location else {
|
||||
location += max(visibleRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
|
||||
guard let clampedRange = factory.clampedRange(range),
|
||||
clampedRange.length > 0,
|
||||
!factory.attachmentRanges(in: clampedRange).isEmpty else {
|
||||
return range
|
||||
}
|
||||
|
||||
let pageContent = attributedString.attributedSubstring(from: clampedRange)
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
return NSRange(location: clampedRange.location, length: visibleRange.length)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - WXRead 分页对齐
|
||||
|
||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。
|
||||
private func proposedRangeUsingWXReadPageCount(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
usableHeight: CGFloat,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
var pageCharCount = 0
|
||||
for (index, line) in lines.enumerated() {
|
||||
let lineRange = CTLineGetStringRange(line)
|
||||
let lineY = origins[index].y
|
||||
var ascent: CGFloat = 0
|
||||
var descent: CGFloat = 0
|
||||
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
|
||||
|
||||
if lineY - ascent > usableHeight {
|
||||
break
|
||||
}
|
||||
|
||||
pageCharCount += lineRange.length
|
||||
}
|
||||
|
||||
if pageCharCount == 0 {
|
||||
pageCharCount = 1
|
||||
}
|
||||
|
||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,389 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 帧工厂:负责帧创建、行级裁剪、属性查询和诊断构建。
|
||||
///
|
||||
/// 叶节点组件,被 RDEPUBChapterPageCounter 和 RDEPUBPageBreakPolicy 调用。
|
||||
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
let attributedString: NSAttributedString
|
||||
let pageSize: CGSize
|
||||
let config: RDEPUBTextLayoutConfig
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
self.attributedString = attributedString
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
|
||||
}
|
||||
|
||||
/// 协议要求的便捷初始化(使用默认配置)
|
||||
init() {
|
||||
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
|
||||
}
|
||||
|
||||
// MARK: - RDEPUBPageFrameBuilding 协议
|
||||
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
|
||||
let counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
// MARK: - 帧构建
|
||||
|
||||
/// 从列矩形构建 CGPath(用于 CTFramesetterCreateFrame)。
|
||||
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||||
let columnRects = config.columnRects(fallback: pageSize)
|
||||
guard columnRects.count > 1 else {
|
||||
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
|
||||
let path = CGMutablePath()
|
||||
for rect in columnRects {
|
||||
path.addRect(rect)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - 行级裁剪
|
||||
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else { return proposed }
|
||||
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行(通用路径)。
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if pageBreakPolicy.lineIsInKeepWithNextBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
// MARK: - 行信息提取
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
static func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
let lineRange = CTLineGetStringRange($0)
|
||||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
}
|
||||
return lines.map { $0.stringRange() }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 属性查询
|
||||
|
||||
/// 获取指定位置的块级元素范围
|
||||
func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
if let encodedRange = attributes[.rdPageBlockRange] as? String {
|
||||
return NSRangeFromString(encodedRange)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 获取指定位置的块级元素类型
|
||||
func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取指定位置的附件布局方式
|
||||
func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取包含指定位置的段落范围
|
||||
func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
|
||||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||||
}
|
||||
|
||||
/// 获取指定范围内的所有附件字符范围
|
||||
func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
|
||||
guard value != nil else { return }
|
||||
results.append(attributeRange)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// 获取指定位置的语义提示列表
|
||||
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||||
return rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String else { return }
|
||||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||||
hints.append(hint)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!placements.contains(placement) else {
|
||||
return
|
||||
}
|
||||
placements.append(placement)
|
||||
}
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 将范围裁剪到 attributedString 的合法边界内。
|
||||
func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||||
}
|
||||
guard range.location < attributedString.length else { return nil }
|
||||
|
||||
let maxLength = attributedString.length - range.location
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> String? {
|
||||
fragmentOffsets
|
||||
.filter { $0.value <= location }
|
||||
.max { lhs, rhs in lhs.value < rhs.value }?
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志
|
||||
func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
attachmentRanges: [NSRange],
|
||||
blockRange: NSRange?,
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
trigger: String? = nil
|
||||
) -> [String] {
|
||||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||||
if let blockRange {
|
||||
items.append("block range: \(NSStringFromRange(blockRange))")
|
||||
}
|
||||
if !attachmentRanges.isEmpty {
|
||||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
||||
}
|
||||
if !blockKinds.isEmpty {
|
||||
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !semanticHints.isEmpty {
|
||||
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !attachmentPlacements.isEmpty {
|
||||
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let trigger, !trigger.isEmpty {
|
||||
items.append("semantic trigger: \(trigger)")
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 分页规则策略:语义边界搜索、行级保护检查、范围调整调度器。
|
||||
///
|
||||
/// 不构建 CoreText frame,只基于 attributed string 属性做规则判定。
|
||||
struct RDEPUBPageBreakPolicy {
|
||||
private let attributedString: NSAttributedString
|
||||
|
||||
init(attributedString: NSAttributedString) {
|
||||
self.attributedString = attributedString
|
||||
}
|
||||
|
||||
// MARK: - 行级保护检查
|
||||
|
||||
/// 检查指定行范围是否落在 avoidPageBreakInside 保护块内。
|
||||
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
|
||||
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
|
||||
return
|
||||
}
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.avoidPageBreakInside) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// 检查指定行范围是否落在 keepWithNext 保护块内。
|
||||
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.keepWithNext) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - 范围调整调度器
|
||||
|
||||
/// 对 CoreText/DTCoreText 提出的分页范围进行语义边界调整。
|
||||
///
|
||||
/// 调整优先级:
|
||||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||||
/// 2. pageRelate 跨页关联边界
|
||||
/// 3. 以上都不满足时,使用原始帧限制分页
|
||||
func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
lineRanges: [NSRange],
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> (
|
||||
range: NSRange,
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange?,
|
||||
attachmentRanges: [NSRange],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind],
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
diagnostics: [String]
|
||||
) {
|
||||
let pageEnd = proposedRange.location + proposedRange.length
|
||||
let proposedBlockKinds = factory.blockKinds(in: proposedRange)
|
||||
let proposedSemanticHints = factory.semanticHints(in: proposedRange)
|
||||
let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
|
||||
guard pageEnd < totalLength else {
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||||
attachmentKinds: factory.attachmentKinds(in: proposedRange),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .chapterEnd,
|
||||
range: proposedRange,
|
||||
attachmentRanges: factory.attachmentRanges(in: proposedRange),
|
||||
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||
let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
|
||||
let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
|
||||
let currentBlockKinds = proposedBlockKinds
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
|
||||
// 微信读书特有的跨页关联规则。
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: proposedRange.location + 1,
|
||||
lineRanges: lineRanges,
|
||||
factory: factory
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .semanticBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .semanticBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: factory.diagnostics(
|
||||
reason: .frameLimit,
|
||||
range: proposedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> (location: Int, trigger: String)? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: (location: Int, trigger: String)?
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard !hints.isEmpty else { return }
|
||||
|
||||
if hints.contains(.pageBreakBefore),
|
||||
attributeRange.location > safeRange.location,
|
||||
attributeRange.location >= minimumEnd {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
let attributeEnd = attributeRange.location + attributeRange.length
|
||||
if hints.contains(.pageBreakAfter),
|
||||
attributeEnd > minimumEnd,
|
||||
attributeEnd < safeRange.location + safeRange.length {
|
||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件才触发分页。
|
||||
func preferredAttachmentBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> Int? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = factory.attachmentPlacement(at: location)
|
||||
let blockKind = factory.blockKind(at: location)
|
||||
|
||||
let isBlockLevelAttachment: Bool
|
||||
switch placement {
|
||||
case .centered:
|
||||
isBlockLevelAttachment = true
|
||||
case .inline, .baseline:
|
||||
isBlockLevelAttachment = false
|
||||
case nil:
|
||||
isBlockLevelAttachment = blockKind == .attachment
|
||||
}
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
|
||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界。
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
lineRanges: [NSRange],
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> Int? {
|
||||
let pageStartOfNext = range.location + range.length
|
||||
guard pageStartOfNext > range.location,
|
||||
pageStartOfNext < attributedString.length,
|
||||
lineRanges.count >= 2,
|
||||
factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
|
||||
?? factory.paragraphRange(containing: pageStartOfNext).location
|
||||
guard boundaryBlockStart == pageStartOfNext else { return nil }
|
||||
|
||||
let lastLineStart = lineRanges[lineRanges.count - 1].location
|
||||
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
|
||||
return nil
|
||||
}
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
// MARK: - 内部工具
|
||||
|
||||
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
||||
return false
|
||||
}
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard hints.contains(.avoidPageBreakInside) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
|
||||
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
||||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
|
||||
if blockKind == .attachment, placement != .centered {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
|
||||
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
|
||||
}
|
||||
|
||||
private func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||||
}
|
||||
guard range.location < attributedString.length else { return nil }
|
||||
|
||||
let maxLength = attributedString.length - range.location
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 分页引擎 Facade:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
|
||||
///
|
||||
/// 内部委托给三个组件:
|
||||
/// - RDEPUBCoreTextPageFrameFactory — 帧创建、属性查询、诊断
|
||||
/// - RDEPUBPageBreakPolicy — 分页规则(语义保护、keepWithNext)
|
||||
/// - RDEPUBChapterPageCounter — 顶层分页循环编排
|
||||
struct RDEPUBTextLayouter {
|
||||
private let counter: RDEPUBChapterPageCounter
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
|
||||
self.counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - 分页接口定义
|
||||
|
||||
struct RDEPUBPageBreakDecision {
|
||||
var range: NSRange
|
||||
var reason: RDEPUBTextPageBreakReason
|
||||
var diagnostics: [String]
|
||||
}
|
||||
|
||||
protocol RDEPUBChapterPageCounting {
|
||||
func pageRanges(
|
||||
for attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBPageBreakDecision]
|
||||
}
|
||||
|
||||
protocol RDEPUBPageFrameBuilding {
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
config: RDEPUBTextLayoutConfig,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> [RDEPUBTextLayoutFrame]
|
||||
}
|
||||
@ -18,8 +18,9 @@ extension NSAttributedString {
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
config: RDEPUBTextLayoutConfig = .default
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
RDEPUBTextLayouter(attributedString: self, pageSize: size, config: config)
|
||||
.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
|
||||
let counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
|
||||
@ -25,13 +25,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
|
||||
///
|
||||
/// 渲染流程:
|
||||
/// 1. 将 HTML 字符串编码为 Data
|
||||
/// 2. 通过 DTCoreText 解析为富文本(失败则回退到纯文本)
|
||||
/// 3. 注入分页语义标记(${rd-sem-start/end} → 属性字典)
|
||||
/// 4. 提取 fragment 偏移量映射表
|
||||
/// 5. 规范化阅读属性(字体、行距、颜色统一)
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
@ -46,8 +39,8 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
@ -65,22 +58,24 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: "",
|
||||
title: "",
|
||||
rawHTML: html,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: nil
|
||||
)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: "",
|
||||
title: "",
|
||||
rawHTML: html,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: nil
|
||||
)
|
||||
).request
|
||||
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)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
@ -91,9 +86,6 @@ 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,
|
||||
@ -102,7 +94,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
)
|
||||
builder?.willFlushCallback = { element in
|
||||
guard let element else { return }
|
||||
RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering(
|
||||
RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering(
|
||||
element,
|
||||
style: request.style,
|
||||
maxImageSize: resolvedMaxImageSize(for: request)
|
||||
@ -112,9 +104,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
|
||||
///
|
||||
/// - 注意:行高倍率计算公式为 `(字体行高 + 行间距) / 字体行高`,
|
||||
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let maxImageSize = resolvedMaxImageSize(for: request)
|
||||
@ -139,16 +128,12 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
|
||||
if let pageSize = request.pageSize {
|
||||
let layoutConfig = request.layoutConfig ?? .default
|
||||
let contentRect = layoutConfig.contentRect(fallback: pageSize)
|
||||
let maxWidth = max(round(contentRect.width), 1)
|
||||
let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
|
||||
return CGSize(width: maxWidth, height: maxHeight)
|
||||
}
|
||||
|
||||
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||
return CGSize(width: max(round(screenBounds.width), 1), height: max(round(screenBounds.height * 0.85), 1))
|
||||
let layoutConfig = request.layoutConfig ?? .default
|
||||
let fallbackPageSize = request.pageSize ?? layoutConfig.fallbackViewportSize
|
||||
let contentRect = layoutConfig.contentRect(fallback: fallbackPageSize)
|
||||
let maxWidth = max(round(contentRect.width), 1)
|
||||
let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
|
||||
return CGSize(width: maxWidth, height: maxHeight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1,798 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 章节分页诊断数据
|
||||
|
||||
/// 单个章节的分页诊断信息,用于调试和质量检测。
|
||||
/// 记录页数、分页原因、附件/块级元素统计等。
|
||||
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var pageCount: Int
|
||||
/// 每页的分页原因列表
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
/// 包含附件的页数
|
||||
public var attachmentPageCount: Int
|
||||
/// 因块级/附件边界调整而分页的页数
|
||||
public var blockAdjustedPageCount: Int
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 采样诊断日志(最多 4 条)
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
///
|
||||
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
|
||||
/// `contentRange` 标记了该页在章节富文本中的字符范围。
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
/// 全书绝对页码(从 0 开始)
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
/// 该页在所属章节中的相对页码(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
/// 所属章节的完整富文本内容(用于跨页查询)
|
||||
public var chapterContent: NSAttributedString
|
||||
/// 该页的富文本片段
|
||||
public var content: NSAttributedString
|
||||
/// 该页在章节富文本中的范围
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
/// 页的元数据(分页原因、附件、语义标记等)
|
||||
public var metadata: RDEPUBTextPageMetadata
|
||||
}
|
||||
|
||||
// MARK: - 章节数据模型
|
||||
|
||||
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
/// 章节完整富文本内容
|
||||
public var attributedContent: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍模型
|
||||
|
||||
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
|
||||
///
|
||||
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
|
||||
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
/// 对标 WXRead 的位置转换器(文件位置 <-> 全书字符位置 <-> 页码)
|
||||
public var positionConverter: RDEPUBTextPositionConverter {
|
||||
RDEPUBTextPositionConverter(book: self)
|
||||
}
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
self.pages = pages
|
||||
self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
|
||||
}
|
||||
|
||||
public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
/// 按 href 获取章节的数据访问层(包含索引表)
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按 spine 索引获取章节的数据访问层。
|
||||
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按绝对页码(从 1 开始)获取章节的数据访问层。
|
||||
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
|
||||
guard let page = page(at: pageNumber) else { return nil }
|
||||
return chapterData(forSpineIndex: page.spineIndex)
|
||||
}
|
||||
|
||||
/// 根据持久化位置解析所属章节的数据访问层。
|
||||
public func chapterData(
|
||||
for location: RDEPUBLocation,
|
||||
resolver: RDEPUBResourceResolver,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBChapterData? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: chapter.spineIndex,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
/// 根据持久化位置计算对应页码(从 1 开始)。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor 锚点定位
|
||||
/// 2. fragment 片段 ID
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
if let anchor = indexTable.anchor(for: normalizedLocation),
|
||||
let page = positionConverter.pageNumber(for: anchor) {
|
||||
return page
|
||||
}
|
||||
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let chapterData = chapterData(forPageNumber: pageNumber),
|
||||
let page = page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍构建器
|
||||
|
||||
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
|
||||
///
|
||||
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
|
||||
///
|
||||
/// 特性:
|
||||
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
/// - 性能采样(记录每章渲染/分页耗时)
|
||||
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
|
||||
/// - 封面章节特殊处理
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
private let sampler: RDEPUBTextPerformanceSampler
|
||||
|
||||
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
||||
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
|
||||
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
|
||||
/// 最后一次构建的性能采样数据
|
||||
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 最后一次构建的缓存命中/未命中统计
|
||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||
|
||||
public init(
|
||||
renderer: RDEPUBTextRenderer,
|
||||
cache: RDEPUBTextBookCache? = nil,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
self.renderer = renderer
|
||||
self.cache = cache
|
||||
self.layoutConfig = layoutConfig
|
||||
self.sampler = RDEPUBTextPerformanceSampler()
|
||||
}
|
||||
|
||||
/// 默认构造器,使用 DTCoreText 渲染器
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
private var isPaginationDebugEnabled: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
||||
}
|
||||
|
||||
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
||||
|
||||
let blockKinds = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.attachmentPlacements))
|
||||
let note = lastBuildPaginationDiagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
|
||||
|
||||
var parts = [
|
||||
title,
|
||||
"章节 \(lastBuildPaginationDiagnostics.count)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 核心构建方法:从 EPUB publication 构建分页书籍。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 生成缓存键,尝试加载分页缓存
|
||||
/// 2. 遍历 spine 中的线性 HTML 章节
|
||||
/// 3. 渲染每章 HTML → NSAttributedString
|
||||
/// 4. 跳过空白的封面/扉页章节
|
||||
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
|
||||
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
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 = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = normalizeTrailingFrames(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
))
|
||||
if isCacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
RDEPUBTextChapterPaginationDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pageCount: pages.count,
|
||||
breakReasons: pages.map(\.metadata.breakReason),
|
||||
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
|
||||
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
|
||||
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
|
||||
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
|
||||
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
|
||||
sampleNotes: Array(
|
||||
pages
|
||||
.flatMap(\.metadata.diagnostics)
|
||||
.prefix(4)
|
||||
)
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
if let cacheKey {
|
||||
let paginationCache = chapters.map { chapter in
|
||||
let pageRanges = chapter.pages.map(\.contentRange)
|
||||
let breakReasons = chapter.pages.map(\.metadata.breakReason)
|
||||
let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
|
||||
return RDEPUBTextChapterPaginationCache(
|
||||
href: chapter.href,
|
||||
pageRanges: pageRanges,
|
||||
breakReasons: breakReasons,
|
||||
semanticHints: semanticHints
|
||||
)
|
||||
}
|
||||
cache?.save(paginationCache, key: cacheKey)
|
||||
}
|
||||
|
||||
print(sampler.summary())
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
/// 递归展开嵌套目录为扁平列表
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节过滤
|
||||
|
||||
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
var hasAttachment = false
|
||||
if content.length > 0 {
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||
guard value != nil else { return }
|
||||
hasAttachment = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - 附件统计
|
||||
|
||||
/// 统计富文本中的附件数量
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
|
||||
if value != nil {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/// 获取富文本中所有附件的 NSRange 列表
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||
if value != nil {
|
||||
ranges.append(range)
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - 封面章节检测
|
||||
|
||||
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
plainText: String
|
||||
) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
guard lowercasedHref.contains("cover") else { return false }
|
||||
let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
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)…"
|
||||
}
|
||||
|
||||
// MARK: - 尾页规范化
|
||||
|
||||
/// 规范化分页结果:
|
||||
/// 1. 移除章节中间误产生的纯空白页
|
||||
/// 2. 丢弃纯空白的尾页
|
||||
/// 3. 将过短的尾页(≤2 字符)合并到前一页
|
||||
private func normalizeTrailingFrames(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
href: String
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
guard frames.count > 1 else { return frames }
|
||||
|
||||
var normalized = frames
|
||||
|
||||
// 先移除章节中间的纯空白页。这类页通常只包含换行/占位空白,
|
||||
// 会把相邻正文页硬生生拆开,直接表现为“上一页底部空很多”。
|
||||
var compacted: [RDEPUBTextLayoutFrame] = []
|
||||
compacted.reserveCapacity(normalized.count)
|
||||
for frame in normalized {
|
||||
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
|
||||
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
|
||||
if var previous = compacted.popLast() {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
compacted.append(frame)
|
||||
}
|
||||
normalized = compacted
|
||||
|
||||
// 丢弃纯空白的尾页
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
||||
if var previousFrame = normalized.popLast() {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||
}
|
||||
}
|
||||
|
||||
// 将过短的尾页合并到前一页
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
|
||||
normalized.removeLast(2)
|
||||
normalized.append(mergedFrame)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
||||
private func shouldDropWhitespaceOnlyFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard frame.contentRange.length > 0,
|
||||
attachmentCount(in: content, range: frame.contentRange) == 0 else {
|
||||
return false
|
||||
}
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
/// 判断尾页是否过短需要合并到前一页。
|
||||
/// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符)
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard trailingFrame.contentRange.length > 0,
|
||||
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
||||
return false
|
||||
}
|
||||
|
||||
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
|
||||
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
|
||||
guard visibleCount <= 2,
|
||||
trailingFrame.contentRange.length <= 2,
|
||||
visibleCount > 0 || trailingAttachmentCount > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
/// 将尾页合并到前一页,保留尾页的分页原因
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
) -> RDEPUBTextLayoutFrame {
|
||||
let mergedRange = NSRange(
|
||||
location: previousFrame.contentRange.location,
|
||||
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
||||
)
|
||||
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: mergedRange,
|
||||
breakReason: trailingFrame.breakReason,
|
||||
blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
|
||||
attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
|
||||
attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
|
||||
blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
|
||||
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
|
||||
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
|
||||
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
|
||||
diagnostics: previousFrame.diagnostics
|
||||
+ trailingFrame.diagnostics
|
||||
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
|
||||
)
|
||||
}
|
||||
|
||||
/// 统计指定范围内可见字符数(排除空白和控制字符)
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
) -> Int {
|
||||
guard range.length > 0 else { return 0 }
|
||||
let string = content.attributedSubstring(from: range).string
|
||||
let filteredScalars = string.unicodeScalars.filter { scalar in
|
||||
!CharacterSet.whitespacesAndNewlines.contains(scalar)
|
||||
&& !CharacterSet.controlCharacters.contains(scalar)
|
||||
}
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
// MARK: - 集合工具方法
|
||||
|
||||
/// 数组去重(保持顺序)
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NSRange 数组去重(保持顺序)
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计指定范围内附件数量
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
content.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
||||
if value != nil {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: - 缓存键生成
|
||||
|
||||
/// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> String? {
|
||||
guard let cache else { return nil }
|
||||
return cache.cacheKey(
|
||||
bookID: bookID,
|
||||
fontSize: style.font.pointSize,
|
||||
lineHeightMultiple: style.lineSpacing,
|
||||
contentInsets: layoutConfig.edgeInsets,
|
||||
pageSize: pageSize,
|
||||
layoutConfigSignature: layoutConfig.cacheSignature
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,946 +0,0 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
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
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
self.attributedString = attributedString
|
||||
self.pageSize = pageSize
|
||||
self.config = config
|
||||
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
|
||||
self.dtLayoutRect = config.contentRect(fallback: pageSize)
|
||||
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
/// 根据编译环境选择 DTCoreText 或 CoreText 路径。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
#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 []
|
||||
}
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
||||
|
||||
guard usableWidth > 0, usableHeight > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = CGMutablePath()
|
||||
// 对齐 WXRead WRChapterPageCount:
|
||||
// CoreText 分页路径使用 bottom inset 作为 y 原点,而不是 UIKit 语义的 top inset。
|
||||
let pageRect = CGRect(
|
||||
x: config.edgeInsets.left,
|
||||
y: config.edgeInsets.bottom,
|
||||
width: usableWidth,
|
||||
height: usableHeight
|
||||
)
|
||||
framePath.addRect(pageRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
||||
from: frame,
|
||||
start: location,
|
||||
usableHeight: usableHeight,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let lineRanges = lineRanges(from: frame)
|
||||
|
||||
let adjusted = adjustedRange(
|
||||
from: avoidAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: adjusted.range,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: adjusted.blockRange,
|
||||
attachmentRanges: adjusted.attachmentRanges,
|
||||
attachmentKinds: adjusted.attachmentKinds,
|
||||
blockKinds: adjusted.blockKinds,
|
||||
semanticHints: adjusted.semanticHints,
|
||||
attachmentPlacements: adjusted.attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: adjusted.diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||
guard nextLocation > location else {
|
||||
location += max(proposedRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard config.numberOfColumns == 1 else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let pageRect = dtLayoutRect
|
||||
|
||||
while location < attributedString.length {
|
||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||
break
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: layoutFrame)
|
||||
let adjusted = adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
if adjusted.breakReason == .attachmentBoundary {
|
||||
verifiedRange = verifiedDisplayRange(for: adjusted.range)
|
||||
} else {
|
||||
verifiedRange = adjusted.range
|
||||
}
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: verifiedRange.location + verifiedRange.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
let diagnostics = verifiedRange == adjusted.range
|
||||
? adjusted.diagnostics
|
||||
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
|
||||
|
||||
frames.append(
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: verifiedRange,
|
||||
breakReason: adjusted.breakReason,
|
||||
blockRange: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
|
||||
attachmentRanges: attachmentRanges(in: verifiedRange),
|
||||
attachmentKinds: attachmentKinds(in: verifiedRange),
|
||||
blockKinds: blockKinds(in: verifiedRange),
|
||||
semanticHints: semanticHints(in: verifiedRange),
|
||||
attachmentPlacements: attachmentPlacements(in: verifiedRange),
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
let nextLocation = verifiedRange.location + verifiedRange.length
|
||||
guard nextLocation > location else {
|
||||
location += max(visibleRange.length, 1)
|
||||
continue
|
||||
}
|
||||
location = nextLocation
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
|
||||
guard let clampedRange = clampedRange(range),
|
||||
clampedRange.length > 0,
|
||||
!attachmentRanges(in: clampedRange).isEmpty else {
|
||||
return range
|
||||
}
|
||||
|
||||
let pageContent = attributedString.attributedSubstring(from: clampedRange)
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
|
||||
return clampedRange
|
||||
}
|
||||
|
||||
return NSRange(location: clampedRange.location, length: visibleRange.length)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||||
let columnRects = config.columnRects(fallback: pageSize)
|
||||
guard columnRects.count > 1 else {
|
||||
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
|
||||
let path = CGMutablePath()
|
||||
for rect in columnRects {
|
||||
path.addRect(rect)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - 语义边界调整
|
||||
|
||||
/// 对 CoreText 提出的分页范围进行语义边界调整。
|
||||
///
|
||||
/// 调整优先级:
|
||||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||||
/// 2. 优先在语义边界(pageBreakBefore/After)分页
|
||||
/// 3. 其次在 pageRelate 跨页关联点分页
|
||||
/// 4. 再次在附件边界分页(块级附件需整体移动)
|
||||
/// 5. 以上都不满足时,使用原始帧限制分页
|
||||
///
|
||||
/// 最小分页长度约束:不低于原始范围的 55%,避免单页内容过少。
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> (
|
||||
range: NSRange,
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
blockRange: NSRange?,
|
||||
attachmentRanges: [NSRange],
|
||||
attachmentKinds: [RDEPUBTextAttachmentKind],
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
diagnostics: [String]
|
||||
) {
|
||||
let pageEnd = proposedRange.location + proposedRange.length
|
||||
let proposedBlockKinds = blockKinds(in: proposedRange)
|
||||
let proposedSemanticHints = semanticHints(in: proposedRange)
|
||||
let proposedAttachmentPlacements = attachmentPlacements(in: proposedRange)
|
||||
guard pageEnd < totalLength else {
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
attachmentRanges: attachmentRanges(in: proposedRange),
|
||||
attachmentKinds: attachmentKinds(in: proposedRange),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .chapterEnd,
|
||||
range: proposedRange,
|
||||
attachmentRanges: attachmentRanges(in: proposedRange),
|
||||
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
|
||||
blockKinds: proposedBlockKinds,
|
||||
semanticHints: proposedSemanticHints,
|
||||
attachmentPlacements: proposedAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
||||
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
|
||||
let currentBlockKinds = proposedBlockKinds
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
|
||||
// 微信读书特有的跨页关联规则;其他 keepWithNext/attachmentBoundary/通用语义边界
|
||||
// 先不参与截断,避免提前裁短页尾内容。
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: proposedRange.location + 1,
|
||||
lineRanges: lineRanges
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
||||
return (
|
||||
range: adjustedRange,
|
||||
breakReason: .semanticBoundary,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .semanticBoundary,
|
||||
range: adjustedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 4. 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
blockRange: currentBlockRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
attachmentKinds: currentAttachmentKinds,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements,
|
||||
diagnostics: diagnostics(
|
||||
reason: .frameLimit,
|
||||
range: proposedRange,
|
||||
attachmentRanges: currentAttachmentRanges,
|
||||
blockRange: currentBlockRange,
|
||||
blockKinds: currentBlockKinds,
|
||||
semanticHints: currentSemanticHints,
|
||||
attachmentPlacements: currentAttachmentPlacements
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
///
|
||||
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
|
||||
private func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int
|
||||
) -> (location: Int, trigger: String)? {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: (location: Int, trigger: String)?
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard !hints.isEmpty else { return }
|
||||
|
||||
if hints.contains(.pageBreakBefore),
|
||||
attributeRange.location > safeRange.location,
|
||||
attributeRange.location >= minimumEnd {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
let attributeEnd = attributeRange.location + attributeRange.length
|
||||
if hints.contains(.pageBreakAfter),
|
||||
attributeEnd > minimumEnd,
|
||||
attributeEnd < safeRange.location + safeRange.length {
|
||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
||||
/// 行内脚注图标等不应导致整段移动。
|
||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
||||
// 行内脚注图标等行内附件不应导致整段移动。
|
||||
// 只有真正的块级附件才需要整块挪页。
|
||||
// 行内/基线对齐的附件(例如脚注 note.png)不能触发附件边界分页。
|
||||
let isBlockLevelAttachment: Bool
|
||||
switch placement {
|
||||
case .centered:
|
||||
isBlockLevelAttachment = true
|
||||
case .inline, .baseline:
|
||||
isBlockLevelAttachment = false
|
||||
case nil:
|
||||
isBlockLevelAttachment = blockKind == .attachment
|
||||
}
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
|
||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
|
||||
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> Int? {
|
||||
let pageStartOfNext = range.location + range.length
|
||||
guard pageStartOfNext > range.location,
|
||||
pageStartOfNext < attributedString.length,
|
||||
lineRanges.count >= 2,
|
||||
semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundaryBlockStart = blockRange(at: pageStartOfNext)?.location ?? paragraphRange(containing: pageStartOfNext).location
|
||||
guard boundaryBlockStart == pageStartOfNext else { return nil }
|
||||
|
||||
let lastLineStart = lineRanges[lineRanges.count - 1].location
|
||||
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
if let encodedRange = attributes[.rdPageBlockRange] as? String {
|
||||
return NSRangeFromString(encodedRange)
|
||||
}
|
||||
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)
|
||||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||||
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)
|
||||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||||
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) }
|
||||
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
|
||||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||||
}
|
||||
|
||||
/// 获取指定范围内的所有附件字符范围
|
||||
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
|
||||
guard value != nil else { return }
|
||||
results.append(attributeRange)
|
||||
}
|
||||
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)
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||||
return rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!kinds.contains(kind) else {
|
||||
return
|
||||
}
|
||||
kinds.append(kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String else { return }
|
||||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||||
hints.append(hint)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
}
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
|
||||
guard let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!placements.contains(placement) else {
|
||||
return
|
||||
}
|
||||
placements.append(placement)
|
||||
}
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 将范围裁剪到 attributedString 的合法边界内,避免属性枚举与子串提取越界。
|
||||
private func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
||||
}
|
||||
guard range.location < attributedString.length else { return nil }
|
||||
|
||||
let maxLength = attributedString.length - range.location
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
|
||||
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
|
||||
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
private func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
) -> String? {
|
||||
fragmentOffsets
|
||||
.filter { $0.value <= location }
|
||||
.max { lhs, rhs in lhs.value < rhs.value }?
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - 行级 avoidPageBreakInside(WXRead 方案)
|
||||
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
/// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
|
||||
/// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else { return proposed }
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
// 从最后一行向前扫描,统计落在保护块内的连续尾部行数
|
||||
// kMaxLinesToRemove = 3(与 WXRead 一致)
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for i in stride(from: lines.count - 1, through: 0, by: -1) {
|
||||
let lineRange = CTLineGetStringRange(lines[i])
|
||||
let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
|
||||
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// 不超过 kMaxLinesToRemove 行,停在这里
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// 所有行都在保护块内,回退到原始范围
|
||||
return proposed
|
||||
}
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = CTLineGetStringRange(lastValidLine)
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 行级处理
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard config.avoidPageBreakInsideEnabled else { return proposed }
|
||||
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for line in lines.reversed() {
|
||||
let lineRange = line.stringRange()
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lines[validLineCount - 1]
|
||||
let lastLineRange = lastValidLine.stringRange()
|
||||
let endLocation = lastLineRange.location + lastLineRange.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
/// 最多移除 3 行。
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if lineIsInKeepWithNextBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 检查某行是否落在 avoidPageBreakInside 保护块内
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
|
||||
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
|
||||
return
|
||||
}
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.avoidPageBreakInside) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// 只有真正的块级内容才应触发 avoidPageBreakInside。
|
||||
/// 像脚注 note.png 这类行内图片会被标记为 `img`,但不该把整行推到下一页。
|
||||
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
||||
return false
|
||||
}
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard hints.contains(.avoidPageBreakInside) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
|
||||
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
||||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
|
||||
if blockKind == .attachment, placement != .centered {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// 检查某行是否落在 keepWithNext 保护块内
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
}
|
||||
var found = false
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.keepWithNext) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
let lineRange = CTLineGetStringRange($0)
|
||||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环:
|
||||
/// 1. 取出 CTFrame 当前页的所有行
|
||||
/// 2. 读取每行 origin
|
||||
/// 3. 用 `lineY - ascent > usableHeight` 判断是否越过可用高度
|
||||
/// 4. 累计能容纳行的字符数得到本页范围
|
||||
private func proposedRangeUsingWXReadPageCount(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
usableHeight: CGFloat,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
var pageCharCount = 0
|
||||
for (index, line) in lines.enumerated() {
|
||||
let lineRange = CTLineGetStringRange(line)
|
||||
let lineY = origins[index].y
|
||||
var ascent: CGFloat = 0
|
||||
var descent: CGFloat = 0
|
||||
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
|
||||
|
||||
if lineY - ascent > usableHeight {
|
||||
break
|
||||
}
|
||||
|
||||
pageCharCount += lineRange.length
|
||||
}
|
||||
|
||||
if pageCharCount == 0 {
|
||||
pageCharCount = 1
|
||||
}
|
||||
|
||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
}
|
||||
return lines.map { $0.stringRange() }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志,记录分页原因、范围和语义信息
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
attachmentRanges: [NSRange],
|
||||
blockRange: NSRange?,
|
||||
blockKinds: [RDEPUBTextBlockKind],
|
||||
semanticHints: [RDEPUBTextSemanticHint],
|
||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
|
||||
trigger: String? = nil
|
||||
) -> [String] {
|
||||
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
|
||||
if let blockRange {
|
||||
items.append("block range: \(NSStringFromRange(blockRange))")
|
||||
}
|
||||
if !attachmentRanges.isEmpty {
|
||||
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
|
||||
}
|
||||
if !blockKinds.isEmpty {
|
||||
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !semanticHints.isEmpty {
|
||||
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if !attachmentPlacements.isEmpty {
|
||||
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let trigger, !trigger.isEmpty {
|
||||
items.append("semantic trigger: \(trigger)")
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
@ -100,6 +100,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
public var hyphenation: Bool
|
||||
/// 图片最大高度占页面高度的比例
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
/// 当调用方暂时拿不到 pageSize 时,用于估算附件尺寸的兜底 viewport。
|
||||
public var fallbackViewportSize: CGSize
|
||||
|
||||
public init(
|
||||
frameWidth: CGFloat = 0,
|
||||
@ -111,7 +113,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
avoidWidows: Bool = true,
|
||||
avoidPageBreakInsideEnabled: Bool = true,
|
||||
hyphenation: Bool = true,
|
||||
imageMaxHeightRatio: CGFloat = 0.85
|
||||
imageMaxHeightRatio: CGFloat = 0.85,
|
||||
fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
|
||||
) {
|
||||
self.frameWidth = frameWidth
|
||||
self.frameHeight = frameHeight
|
||||
@ -123,6 +126,7 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||||
self.hyphenation = hyphenation
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
self.fallbackViewportSize = fallbackViewportSize
|
||||
}
|
||||
|
||||
/// 默认配置
|
||||
@ -172,7 +176,9 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
avoidWidows ? "1" : "0",
|
||||
avoidPageBreakInsideEnabled ? "1" : "0",
|
||||
hyphenation ? "1" : "0",
|
||||
String(format: "%.3f", imageMaxHeightRatio)
|
||||
String(format: "%.3f", imageMaxHeightRatio),
|
||||
String(format: "%.3f", fallbackViewportSize.width),
|
||||
String(format: "%.3f", fallbackViewportSize.height)
|
||||
].joined(separator: "|")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -43,7 +43,20 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
for (index, spec) in chapterSpecs.enumerated() {
|
||||
let html = wrapTextAsHTML(spec.content)
|
||||
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
||||
let request = RDEPUBTextChapterRenderRequest(
|
||||
context: RDEPUBTextChapterContext(
|
||||
href: "chapter_\(index).xhtml",
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
html: html,
|
||||
baseURL: nil,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
|
||||
resourceDiagnostics: []
|
||||
),
|
||||
style: style,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBAttachmentNormalizer {
|
||||
/// 脚注附件日志已输出标记(避免重复日志)
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
private static var didLogCoverAttachment = false
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
func normalize(
|
||||
_ attachment: DTTextAttachment,
|
||||
fontPointSize: CGFloat,
|
||||
maxImageSize: CGSize
|
||||
) {
|
||||
Self.normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: fontPointSize,
|
||||
maxImageSize: maxImageSize
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - DTCoreText 附件布局规范化
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 对 DTTextAttachment 做尺寸规范化:脚注缩放、封面缩放、通用图片缩放。
|
||||
static func normalizeAttachmentLayoutForWXRead(
|
||||
_ attachment: DTTextAttachment,
|
||||
fontPointSize: CGFloat,
|
||||
maxImageSize: CGSize? = nil
|
||||
) {
|
||||
let pointSize = max(fontPointSize, 1)
|
||||
let originalSize = attachment.originalSize
|
||||
|
||||
if isFootnoteAttachment(attachment) {
|
||||
let targetWidth = max(round(pointSize), 1)
|
||||
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
|
||||
let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
|
||||
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
|
||||
attachment.verticalAlignment = .baseline
|
||||
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isCoverAttachment(attachment) {
|
||||
let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
|
||||
if originalSize.width > 0, originalSize.height > 0 {
|
||||
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
|
||||
attachment.displaySize = CGSize(
|
||||
width: round(originalSize.width * scale),
|
||||
height: round(originalSize.height * scale)
|
||||
)
|
||||
} else {
|
||||
attachment.displaySize = maxSize
|
||||
}
|
||||
attachment.verticalAlignment = .baseline
|
||||
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var resolvedSize = attachment.displaySize
|
||||
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
|
||||
resolvedSize = originalSize
|
||||
}
|
||||
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
|
||||
resolvedSize = CGSize(width: pointSize, height: pointSize)
|
||||
}
|
||||
|
||||
if let maxImageSize,
|
||||
resolvedSize.width > 0,
|
||||
resolvedSize.height > 0,
|
||||
(resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
|
||||
let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
|
||||
resolvedSize = CGSize(
|
||||
width: round(resolvedSize.width * scale),
|
||||
height: round(resolvedSize.height * scale)
|
||||
)
|
||||
}
|
||||
|
||||
attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
|
||||
attachment.verticalAlignment = .center
|
||||
}
|
||||
|
||||
/// DTCoreText 元素配置:在 willFlushCallback 中调用。
|
||||
static func prepareHTMLElementForReaderRendering(
|
||||
_ element: DTHTMLElement,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
maxImageSize: CGSize? = nil
|
||||
) {
|
||||
guard let attachment = element.textAttachment else { return }
|
||||
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
||||
let fallbackSize = CGSize(
|
||||
width: defaultMaxImageSize(fontPointSize: pointSize).width,
|
||||
height: defaultMaxImageSize(fontPointSize: pointSize).height
|
||||
)
|
||||
normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: pointSize,
|
||||
maxImageSize: maxImageSize ?? fallbackSize
|
||||
)
|
||||
if isFootnoteAttachment(attachment) {
|
||||
element.displayStyle = .inline
|
||||
} else if isCoverAttachment(attachment) {
|
||||
element.displayStyle = .block
|
||||
}
|
||||
}
|
||||
|
||||
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
|
||||
}
|
||||
|
||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
|
||||
}
|
||||
|
||||
private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
|
||||
let referenceViewport = CGSize(width: 375, height: 667)
|
||||
let horizontalInset = max(round(fontPointSize), 16)
|
||||
let verticalInset = max(round(fontPointSize * 1.5), 28)
|
||||
return CGSize(
|
||||
width: max(round(referenceViewport.width - horizontalInset * 2), 1),
|
||||
height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 通用附件规范化
|
||||
|
||||
/// 规范化 NSAttributedString 中附件的显示尺寸。
|
||||
static func normalizeAttachmentDisplayIfNeeded(
|
||||
in attributes: inout [NSAttributedString.Key: Any],
|
||||
font: UIFont
|
||||
) {
|
||||
guard let attachment = attributes[.attachment] else { return }
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
if let textAttachment = attachment as? DTTextAttachment {
|
||||
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
|
||||
attributes[.attachment] = textAttachment
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
|
||||
let targetHeight = max(round(font.pointSize * 0.86), 1)
|
||||
textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
|
||||
attributes[.attachment] = textAttachment
|
||||
}
|
||||
}
|
||||
|
||||
/// 推断附件类型。
|
||||
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
|
||||
if let attachment = attributes[.attachment] as? NSTextAttachment {
|
||||
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
|
||||
return .image
|
||||
}
|
||||
return .generic
|
||||
}
|
||||
|
||||
for value in attributes.values {
|
||||
let typeName = String(describing: type(of: value)).lowercased()
|
||||
if typeName.contains("attachment") {
|
||||
return typeName.contains("image") ? .image : .generic
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
struct RDEPUBFontNormalizer {
|
||||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
|
||||
func registerEmbeddedFonts(
|
||||
html: String,
|
||||
inlinedCSS: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
) {
|
||||
Self.registerEmbeddedFonts(
|
||||
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
|
||||
chapterHref: input.href,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 字体注册
|
||||
|
||||
/// 从 CSS 中解析 @font-face 规则,注册嵌入字体。
|
||||
static func registerEmbeddedFonts(
|
||||
in css: String,
|
||||
chapterHref: String,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) {
|
||||
guard let resourceResolver,
|
||||
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
|
||||
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
|
||||
guard faceMatch.numberOfRanges > 1 else { continue }
|
||||
let block = nsCSS.substring(with: faceMatch.range(at: 1))
|
||||
let nsBlock = block as NSString
|
||||
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
|
||||
guard urlMatch.numberOfRanges > 1 else { continue }
|
||||
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
|
||||
guard !rawReference.isEmpty,
|
||||
!rawReference.hasPrefix("data:"),
|
||||
!rawReference.hasPrefix("http://"),
|
||||
!rawReference.hasPrefix("https://"),
|
||||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||||
continue
|
||||
}
|
||||
registerFontIfNeeded(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func registerFontIfNeeded(at fileURL: URL) {
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||||
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
}
|
||||
|
||||
// MARK: - 字体标准化
|
||||
|
||||
/// 将 EPUB 原始字体映射到用户设置字体,保留粗体/斜体特征。
|
||||
static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
return baseFont
|
||||
}
|
||||
|
||||
/// 提取 HTML 中内联 <style> 块的 CSS 内容。
|
||||
static func inlineStyleCSS(in html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
|
||||
return ""
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
.compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
return nsHTML.substring(with: match.range(at: 1))
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectFragmentMarkers(into: html)
|
||||
}
|
||||
|
||||
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
Self.extractFragmentOffsets(from: attributedString)
|
||||
}
|
||||
|
||||
// MARK: - Fragment 标记注入
|
||||
|
||||
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
|
||||
/// `<tag id="xxx" ...>` → `${id=xxx}<tag id="xxx" ...>`
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Fragment 偏移量提取
|
||||
|
||||
/// 从渲染后的富文本中提取 fragment 偏移量映射。
|
||||
/// 扫描 `${id=xxx}` 标记,记录偏移量,然后删除标记文本。
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var offsetAdjustment = 0
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
offsetAdjustment -= match.range.length
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.normalizeHTML(html)
|
||||
}
|
||||
|
||||
// MARK: - HTML 规范化入口
|
||||
|
||||
/// 清理冗余字符(CR、多余换行),规范化附件 HTML 标记。
|
||||
static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
// MARK: - 附件 HTML 标记规范化
|
||||
|
||||
/// 处理 bodyPic div、脚注 img、封面 h1+img 等特殊 HTML 结构。
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
if let bodyPicContainerRegex = try? NSRegularExpression(
|
||||
pattern: #"<div\b([^>]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)</div>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: bodyPicContainerRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
|
||||
return tag
|
||||
}
|
||||
|
||||
return replaceMatches(
|
||||
using: imageTagRegex,
|
||||
in: tag
|
||||
) { imageTag in
|
||||
mergeHTMLAttributes(
|
||||
into: imageTag,
|
||||
requiredClass: "bodyPic",
|
||||
styleFragments: [
|
||||
"wr-vertical-center-style:2",
|
||||
"max-width:100%",
|
||||
"height:auto",
|
||||
"display:block",
|
||||
"margin-left:auto",
|
||||
"margin-right:auto"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let footnoteRegex = try? NSRegularExpression(
|
||||
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: footnoteRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
mergeHTMLAttributes(
|
||||
into: tag,
|
||||
requiredClass: nil,
|
||||
styleFragments: [
|
||||
"width:1em",
|
||||
"height:1em",
|
||||
"vertical-align:middle",
|
||||
"display:inline-block"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let coverRegex = try? NSRegularExpression(
|
||||
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
normalized = replaceMatches(
|
||||
using: coverRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
|
||||
let imageMatch = imageTagRegex.firstMatch(
|
||||
in: tag,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: (tag as NSString).length)
|
||||
),
|
||||
let imageRange = Range(imageMatch.range, in: tag) else {
|
||||
return tag
|
||||
}
|
||||
|
||||
let imageTag = String(tag[imageRange])
|
||||
let normalizedImageTag = mergeHTMLAttributes(
|
||||
into: imageTag,
|
||||
requiredClass: "rd-front-cover-image",
|
||||
styleFragments: [
|
||||
"display:block",
|
||||
"width:100%",
|
||||
"height:auto",
|
||||
"margin-left:auto",
|
||||
"margin-right:auto"
|
||||
]
|
||||
)
|
||||
return tag.replacingCharacters(in: imageRange, with: normalizedImageTag)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// MARK: - HTML 工具方法
|
||||
|
||||
/// 通用反向正则替换:遍历所有匹配,对每个匹配的原始文本调用 transform。
|
||||
static func replaceMatches(
|
||||
using regex: NSRegularExpression,
|
||||
in source: String,
|
||||
transform: (String) -> String
|
||||
) -> String {
|
||||
let nsSource = source as NSString
|
||||
let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
|
||||
guard !matches.isEmpty else { return source }
|
||||
|
||||
var rewritten = source
|
||||
for match in matches.reversed() {
|
||||
guard let range = Range(match.range, in: rewritten) else { continue }
|
||||
let original = String(rewritten[range])
|
||||
rewritten.replaceSubrange(range, with: transform(original))
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
|
||||
/// 合并 HTML 标签的 class 和 style 属性。
|
||||
static func mergeHTMLAttributes(
|
||||
into tag: String,
|
||||
requiredClass: String?,
|
||||
styleFragments: [String]
|
||||
) -> String {
|
||||
var rewritten = tag
|
||||
if let requiredClass {
|
||||
if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||
let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||
match.numberOfRanges > 1 {
|
||||
let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1))
|
||||
if !existingClasses.localizedCaseInsensitiveContains(requiredClass) {
|
||||
let replacement = #"class="\#(existingClasses) \#(requiredClass)""#
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||
rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing)
|
||||
}
|
||||
}
|
||||
|
||||
let styleValue = styleFragments.joined(separator: ";") + ";"
|
||||
if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
|
||||
let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
|
||||
match.numberOfRanges > 1 {
|
||||
let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue
|
||||
let replacement = #"style="\#(merged)""#
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
} else if let closing = rewritten.lastIndex(of: ">") {
|
||||
rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing)
|
||||
}
|
||||
|
||||
return rewritten
|
||||
}
|
||||
|
||||
/// 注入 `<base>` 标签以解析相对路径。
|
||||
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
|
||||
guard let baseURL else {
|
||||
return html
|
||||
}
|
||||
let baseTag = "<base href=\"\(baseURL.absoluteString)\">"
|
||||
if html.range(of: "<base ", options: [.caseInsensitive]) != nil {
|
||||
return html
|
||||
}
|
||||
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(baseTag)", options: [.caseInsensitive])
|
||||
}
|
||||
if let htmlTagRange = html.range(of: "<html", options: [.caseInsensitive]),
|
||||
let htmlRange = html.range(of: ">", range: htmlTagRange.lowerBound..<html.endIndex) {
|
||||
return html.replacingCharacters(in: htmlRange.upperBound..<htmlRange.upperBound, with: "\n<head>\n\(baseTag)\n</head>")
|
||||
}
|
||||
return "<head>\n\(baseTag)\n</head>\n" + html
|
||||
}
|
||||
|
||||
/// 工具方法:从字符串范围构建 CGSize 描述。
|
||||
static func string(from size: CGSize) -> String {
|
||||
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRenderDiagnosticsCollector {
|
||||
/// 外部样式表链接正则
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 图片源地址正则
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
|
||||
func collect(
|
||||
in html: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
) -> [RDEPUBTextResourceReferenceDiagnostic] {
|
||||
Self.collectImageDiagnostics(
|
||||
in: html,
|
||||
chapterHref: input.href,
|
||||
baseURL: input.baseURL,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 图片诊断
|
||||
|
||||
/// 扫描 HTML 中的 <img> 标签,构建资源引用诊断。
|
||||
static func collectImageDiagnostics(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> [RDEPUBTextResourceReferenceDiagnostic] {
|
||||
guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else {
|
||||
return []
|
||||
}
|
||||
let nsHTML = html as NSString
|
||||
return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
|
||||
guard match.numberOfRanges > 1 else { return nil }
|
||||
let href = nsHTML.substring(with: match.range(at: 1))
|
||||
return resolveReference(
|
||||
href,
|
||||
kind: .image,
|
||||
chapterHref: chapterHref,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
).diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 外部样式表内联
|
||||
|
||||
/// 查找 `<link rel=stylesheet>` 标签,内联 CSS 内容。
|
||||
static func inlineLinkedStyleSheets(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) {
|
||||
guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else {
|
||||
return (html, "", [])
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else {
|
||||
return (html, "", [])
|
||||
}
|
||||
|
||||
var rewrittenHTML = html
|
||||
var inlinedCSSBlocks: [String] = []
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let href = nsHTML.substring(with: match.range(at: 1))
|
||||
let resolution = resolveReference(
|
||||
href,
|
||||
kind: .stylesheet,
|
||||
chapterHref: chapterHref,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
diagnostics.append(resolution.diagnostic)
|
||||
|
||||
if let fileURL = resolution.resolvedFileURL,
|
||||
let css = try? String(contentsOf: fileURL),
|
||||
resolution.diagnostic.existsOnDisk {
|
||||
let cssWithResolvedURLs = rewriteCSSResourceURLs(
|
||||
in: css,
|
||||
styleSheetFileURL: fileURL
|
||||
)
|
||||
inlinedCSSBlocks.append(cssWithResolvedURLs)
|
||||
}
|
||||
|
||||
if let range = Range(match.range, in: rewrittenHTML) {
|
||||
rewrittenHTML.replaceSubrange(range, with: "")
|
||||
}
|
||||
}
|
||||
|
||||
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
|
||||
}
|
||||
|
||||
// MARK: - CSS 资源 URL 重写
|
||||
|
||||
/// 重写 CSS 中的相对 url() 引用,解析为绝对文件路径。
|
||||
static func rewriteCSSResourceURLs(
|
||||
in css: String,
|
||||
styleSheetFileURL: URL
|
||||
) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
|
||||
return css
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length))
|
||||
guard !matches.isEmpty else {
|
||||
return css
|
||||
}
|
||||
|
||||
var rewrittenCSS = css
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
guard !rawValue.isEmpty else { continue }
|
||||
if rawValue.hasPrefix("data:") || rawValue.hasPrefix("http://") || rawValue.hasPrefix("https://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") {
|
||||
continue
|
||||
}
|
||||
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
|
||||
continue
|
||||
}
|
||||
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
|
||||
if let range = Range(match.range, in: rewrittenCSS) {
|
||||
rewrittenCSS.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
return rewrittenCSS
|
||||
}
|
||||
|
||||
// MARK: - 资源引用解析
|
||||
|
||||
static func resolveReference(
|
||||
_ reference: String,
|
||||
kind: RDEPUBTextResourceReferenceKind,
|
||||
chapterHref: String,
|
||||
baseURL: URL?,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
|
||||
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
|
||||
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
|
||||
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
|
||||
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
|
||||
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
|
||||
kind: kind,
|
||||
chapterHref: chapterHref,
|
||||
originalReference: trimmedReference,
|
||||
normalizedHref: normalizedHref,
|
||||
resolvedFileURL: resolvedFileURL,
|
||||
existsOnDisk: existsOnDisk
|
||||
)
|
||||
return (normalizedHref, resolvedFileURL, diagnostic)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,332 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectPaginationSemanticMarkers(into: html)
|
||||
}
|
||||
|
||||
func apply(to attributedString: NSMutableAttributedString) {
|
||||
Self.applyPaginationSemantics(in: attributedString)
|
||||
}
|
||||
|
||||
// MARK: - 语义标记注入(HTML 阶段)
|
||||
|
||||
/// 为 HTML 标签注入 ${rd-sem-start/end} 语义标记。
|
||||
static func injectPaginationSemanticMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else {
|
||||
return html
|
||||
}
|
||||
|
||||
var output = ""
|
||||
var cursor = 0
|
||||
var openTagStack: [(name: String, id: String)] = []
|
||||
var nextMarkerID = 0
|
||||
|
||||
for match in matches {
|
||||
let tagRange = match.range
|
||||
guard tagRange.location >= cursor else { continue }
|
||||
output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
|
||||
|
||||
let tag = nsHTML.substring(with: tagRange)
|
||||
let loweredTag = tag.lowercased()
|
||||
let tagName = htmlTagName(from: loweredTag)
|
||||
|
||||
if loweredTag.hasPrefix("</"), let tagName {
|
||||
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
|
||||
let markerID = openTagStack.remove(at: index).id
|
||||
output += semanticEndMarker(id: markerID)
|
||||
}
|
||||
output += tag
|
||||
} else if let tagName,
|
||||
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
|
||||
nextMarkerID += 1
|
||||
let markerID = String(nextMarkerID)
|
||||
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
|
||||
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
|
||||
output += startMarker + tag + semanticEndMarker(id: markerID)
|
||||
} else {
|
||||
openTagStack.append((name: tagName, id: markerID))
|
||||
output += tag + startMarker
|
||||
}
|
||||
} else {
|
||||
output += tag
|
||||
}
|
||||
|
||||
cursor = tagRange.location + tagRange.length
|
||||
}
|
||||
|
||||
output += nsHTML.substring(from: cursor)
|
||||
return output
|
||||
}
|
||||
|
||||
// MARK: - 语义标记应用(渲染后阶段)
|
||||
|
||||
/// 将 HTML 中注入的语义标记解析后写入 NSAttributedString 属性。
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let kind = mutableString.substring(with: match.range(at: 1))
|
||||
let payload = mutableString.substring(with: match.range(at: 2))
|
||||
let markerLocation = match.range.location
|
||||
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
|
||||
if kind == "start" {
|
||||
let semantics = parseSemanticMarkerPayload(payload)
|
||||
openRanges[semantics.id] = (markerLocation, semantics)
|
||||
} else {
|
||||
let markerID = parseSemanticEndID(payload)
|
||||
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
|
||||
let length = max(markerLocation - opened.location, 0)
|
||||
if length > 0 {
|
||||
apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 语义推断
|
||||
|
||||
static func htmlTagName(from loweredTag: String) -> String? {
|
||||
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("<") else { return nil }
|
||||
let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
|
||||
let name = body.prefix { $0.isLetter || $0.isNumber }
|
||||
return name.isEmpty ? nil : String(name)
|
||||
}
|
||||
|
||||
private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
|
||||
let loweredTag = rawTag.lowercased()
|
||||
let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
|
||||
let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
|
||||
let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
|
||||
|
||||
guard blockKind != nil || !hints.isEmpty || placement != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDPaginationSemantics(
|
||||
id: "",
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
|
||||
if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
|
||||
return .attachment
|
||||
}
|
||||
switch tagName {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return .generic
|
||||
case "blockquote":
|
||||
return .blockquote
|
||||
case "ul", "ol", "li":
|
||||
return .list
|
||||
case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
|
||||
return .table
|
||||
case "pre", "code":
|
||||
return .code
|
||||
case "p":
|
||||
return .paragraph
|
||||
case "div":
|
||||
if rawTag.contains("code") || rawTag.contains("highlight") {
|
||||
return .code
|
||||
}
|
||||
if rawTag.contains("quote") || rawTag.contains("blockquote") {
|
||||
return .blockquote
|
||||
}
|
||||
if rawTag.contains("table") {
|
||||
return .table
|
||||
}
|
||||
if rawTag.contains("list") {
|
||||
return .list
|
||||
}
|
||||
return .generic
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
if rawTag.contains("avoidpagebreakinside") ||
|
||||
rawTag.contains("break-inside: avoid") ||
|
||||
rawTag.contains("page-break-inside: avoid") ||
|
||||
["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
|
||||
hints.append(.avoidPageBreakInside)
|
||||
}
|
||||
if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) ||
|
||||
rawTag.contains("subhead") ||
|
||||
rawTag.contains("firsttitle") ||
|
||||
rawTag.contains("secondtitle") ||
|
||||
rawTag.contains("thirdtitle") ||
|
||||
rawTag.contains("fourthtitle") ||
|
||||
rawTag.contains("fifthtitle") ||
|
||||
rawTag.contains("sixthtitle") {
|
||||
hints.append(.keepWithNext)
|
||||
}
|
||||
if rawTag.contains("pagebreakbefore") ||
|
||||
rawTag.contains("page-break-before: always") ||
|
||||
rawTag.contains("break-before: page") {
|
||||
hints.append(.pageBreakBefore)
|
||||
}
|
||||
if rawTag.contains("pagebreakafter") ||
|
||||
rawTag.contains("page-break-after: always") ||
|
||||
rawTag.contains("break-after: page") {
|
||||
hints.append(.pageBreakAfter)
|
||||
}
|
||||
if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
|
||||
hints.append(.pageRelate)
|
||||
}
|
||||
return hints
|
||||
.reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
|
||||
if !result.contains(hint) {
|
||||
result.append(hint)
|
||||
}
|
||||
}
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
|
||||
return nil
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
|
||||
return .centered
|
||||
}
|
||||
if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
|
||||
return .baseline
|
||||
}
|
||||
return .inline
|
||||
}
|
||||
|
||||
private static func isVoidHTMLTag(_ tagName: String) -> Bool {
|
||||
["img", "br", "hr", "input", "meta", "link"].contains(tagName)
|
||||
}
|
||||
|
||||
private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
|
||||
var segments = ["id=\(id)"]
|
||||
if let blockKind = semantics.blockKind {
|
||||
segments.append("block=\(blockKind.rawValue)")
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
segments.append("placement=\(placement.rawValue)")
|
||||
}
|
||||
return "${rd-sem-start:\(segments.joined(separator: ";"))}"
|
||||
}
|
||||
|
||||
private static func semanticEndMarker(id: String) -> String {
|
||||
"${rd-sem-end:id=\(id)}"
|
||||
}
|
||||
|
||||
private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
|
||||
var values: [String: String] = [:]
|
||||
payload.split(separator: ";").forEach { entry in
|
||||
let parts = entry.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else { return }
|
||||
values[String(parts[0])] = String(parts[1])
|
||||
}
|
||||
let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
||||
let hints = values["hints"]?
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
|
||||
let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
||||
return RDPaginationSemantics(
|
||||
id: values["id"] ?? UUID().uuidString,
|
||||
blockKind: blockKind,
|
||||
hints: hints,
|
||||
attachmentPlacement: placement
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseSemanticEndID(_ payload: String) -> String? {
|
||||
payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
|
||||
}
|
||||
|
||||
private static func apply(
|
||||
semantics: RDPaginationSemantics,
|
||||
to range: NSRange,
|
||||
in attributedString: NSMutableAttributedString
|
||||
) {
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
attributes[.rdPageBlockRange] = NSStringFromRange(range)
|
||||
if let blockKind = semantics.blockKind {
|
||||
attributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
if !semantics.hints.isEmpty {
|
||||
attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
if let placement = semantics.attachmentPlacement {
|
||||
attributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
guard !attributes.isEmpty else { return }
|
||||
attributedString.addAttributes(attributes, range: range)
|
||||
}
|
||||
|
||||
/// 推断块级元素类型。
|
||||
static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
|
||||
if let rawValue = attributes[.rdPageBlockKind] as? String,
|
||||
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
|
||||
return blockKind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 推断语义提示列表。
|
||||
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
|
||||
if let rawValue = attributes[.rdPageSemanticHints] as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
return hints.isEmpty ? nil : hints
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 推断附件放置方式。
|
||||
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
|
||||
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
|
||||
return placement
|
||||
}
|
||||
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes), attachmentKind == .image {
|
||||
return .inline
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 内部类型
|
||||
|
||||
struct RDPaginationSemantics {
|
||||
var id: String
|
||||
var blockKind: RDEPUBTextBlockKind?
|
||||
var hints: [RDEPUBTextSemanticHint]
|
||||
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,288 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBStyleSheetComposition {
|
||||
var html: String
|
||||
var layers: [RDEPUBTextStyleSheetLayer]
|
||||
var inlinedCSS: String
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
}
|
||||
|
||||
struct RDEPUBStyleSheetComposer {
|
||||
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: html,
|
||||
chapterHref: input.href,
|
||||
baseURL: input.baseURL,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
let layers = Self.makeStyleSheetLayers(
|
||||
style: input.style,
|
||||
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
contentLanguageCode: input.contentLanguageCode,
|
||||
sourceHTML: input.rawHTML
|
||||
)
|
||||
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(
|
||||
into: stylesheetHrefReplacements.html,
|
||||
baseURL: input.baseURL
|
||||
)
|
||||
let htmlWithDefaultLayers = Self.injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
styleID: "rd-native-default-replace-dark",
|
||||
css: layers
|
||||
.filter { $0.kind != .user && $0.kind != .epub }
|
||||
.map(\.css)
|
||||
.joined(separator: "\n\n"),
|
||||
position: .headStart
|
||||
)
|
||||
let htmlWithEPUBLayer = Self.injectStyleTag(
|
||||
into: htmlWithDefaultLayers,
|
||||
styleID: "rd-native-epub",
|
||||
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
let composedHTML = Self.injectStyleTag(
|
||||
into: htmlWithEPUBLayer,
|
||||
styleID: "rd-native-user",
|
||||
css: layers.first(where: { $0.kind == .user })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
|
||||
return RDEPUBStyleSheetComposition(
|
||||
html: composedHTML,
|
||||
layers: layers,
|
||||
inlinedCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
diagnostics: stylesheetHrefReplacements.diagnostics
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CSS 层组装
|
||||
|
||||
/// 构建五层 CSS 数组(default/replace/dark/epub/user)。
|
||||
static func makeStyleSheetLayers(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
epubCSS: String,
|
||||
contentLanguageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> [RDEPUBTextStyleSheetLayer] {
|
||||
let useLatinReplace = prefersLatinLanguageCSS(
|
||||
languageCode: contentLanguageCode,
|
||||
sourceHTML: sourceHTML
|
||||
)
|
||||
var layers: [RDEPUBTextStyleSheetLayer] = [
|
||||
.init(kind: .default, css: defaultCSS()),
|
||||
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
|
||||
]
|
||||
if isDarkTheme(style: style) {
|
||||
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
|
||||
}
|
||||
if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
layers.append(.init(kind: .epub, css: epubCSS))
|
||||
}
|
||||
layers.append(.init(kind: .user, css: userCSS(style: style)))
|
||||
return layers
|
||||
}
|
||||
|
||||
// MARK: - Style 注入
|
||||
|
||||
enum StyleInjectionPosition {
|
||||
case headStart
|
||||
case headEnd
|
||||
}
|
||||
|
||||
/// 向 HTML 注入 <style> 标签。
|
||||
static func injectStyleTag(
|
||||
into html: String,
|
||||
styleID: String,
|
||||
css: String,
|
||||
position: StyleInjectionPosition
|
||||
) -> String {
|
||||
let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedCSS.isEmpty else {
|
||||
return html
|
||||
}
|
||||
|
||||
let styleTag = "<style id=\"\(styleID)\">\n\(trimmedCSS)\n</style>"
|
||||
switch position {
|
||||
case .headStart:
|
||||
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(styleTag)", options: [.caseInsensitive])
|
||||
}
|
||||
case .headEnd:
|
||||
if html.range(of: "</head>", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "</head>", with: "\(styleTag)\n</head>", options: [.caseInsensitive])
|
||||
}
|
||||
}
|
||||
|
||||
if html.range(of: "<body", options: [.caseInsensitive]) != nil {
|
||||
return html.replacingOccurrences(of: "<body", with: "\(styleTag)\n<body", options: [.caseInsensitive])
|
||||
}
|
||||
return styleTag + "\n" + html
|
||||
}
|
||||
|
||||
// MARK: - 各层 CSS
|
||||
|
||||
private static func defaultCSS() -> String {
|
||||
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||||
}
|
||||
|
||||
private static func replaceCSS(useLatinVariant: Bool) -> String {
|
||||
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
|
||||
return RDEPUBAssetRepository.string(for: asset)
|
||||
}
|
||||
|
||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||||
html, body {
|
||||
background: \(background) !important;
|
||||
color: \(text) !important;
|
||||
}
|
||||
a {
|
||||
color: \(text) !important;
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
|
||||
let text = style.textColor.map { "color: \($0.ss_cssString) !important;" } ?? ""
|
||||
let background = style.backgroundColor.map { "background: \($0.ss_cssString) !important;" } ?? ""
|
||||
return """
|
||||
html, body {
|
||||
font-family: "\(style.font.familyName)" !important;
|
||||
font-size: \(String(format: "%.3f", style.font.pointSize))px !important;
|
||||
line-height: \(String(format: "%.3f", lineHeight)) !important;
|
||||
\(text)
|
||||
\(background)
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool {
|
||||
guard let backgroundColor = style.backgroundColor else {
|
||||
return false
|
||||
}
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
// MARK: - 语言检测
|
||||
|
||||
private static func prefersLatinLanguageCSS(
|
||||
languageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> Bool {
|
||||
let candidateCodes = inferredLanguageCodes(
|
||||
explicitLanguageCode: languageCode,
|
||||
sourceHTML: sourceHTML
|
||||
)
|
||||
|
||||
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
|
||||
return true
|
||||
}
|
||||
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
let textSample = plainTextSample(from: sourceHTML)
|
||||
guard !textSample.isEmpty else { return false }
|
||||
|
||||
var alphabeticCount = 0
|
||||
var latinCount = 0
|
||||
for scalar in textSample.unicodeScalars {
|
||||
guard CharacterSet.letters.contains(scalar) else { continue }
|
||||
alphabeticCount += 1
|
||||
if isLatinScalar(scalar) {
|
||||
latinCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard alphabeticCount >= 80 else { return false }
|
||||
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
|
||||
}
|
||||
|
||||
private static func inferredLanguageCodes(
|
||||
explicitLanguageCode: String?,
|
||||
sourceHTML: String
|
||||
) -> [String] {
|
||||
var codes: [String] = []
|
||||
if let explicitLanguageCode {
|
||||
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if !normalized.isEmpty {
|
||||
codes.append(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if let regex = try? NSRegularExpression(
|
||||
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
|
||||
options: [.caseInsensitive]
|
||||
) {
|
||||
let nsHTML = sourceHTML as NSString
|
||||
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
|
||||
for match in regex.matches(in: sourceHTML, options: [], range: range) {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let code = nsHTML.substring(with: match.range(at: 1))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if !code.isEmpty {
|
||||
codes.append(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
|
||||
}
|
||||
|
||||
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
|
||||
let normalized = code.lowercased()
|
||||
if normalized.contains("latn") {
|
||||
return true
|
||||
}
|
||||
|
||||
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
||||
let latinPrefixes: Set<String> = [
|
||||
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
|
||||
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
|
||||
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
|
||||
]
|
||||
return latinPrefixes.contains(prefix)
|
||||
}
|
||||
|
||||
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
|
||||
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
|
||||
return ["zh", "ja", "ko"].contains(prefix)
|
||||
}
|
||||
|
||||
private static func plainTextSample(from html: String) -> String {
|
||||
let maxLength = min(html.count, 20_000)
|
||||
let sample = String(html.prefix(maxLength))
|
||||
let withoutTags = sample.replacingOccurrences(
|
||||
of: #"<[^>]+>"#,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
return withoutTags.replacingOccurrences(
|
||||
of: #"&[A-Za-z0-9#]+;"#,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
}
|
||||
|
||||
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x0041...0x007A,
|
||||
0x00C0...0x00FF,
|
||||
0x0100...0x024F,
|
||||
0x1E00...0x1EFF:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 渲染器支持 Facade:串联各 stage 完成 HTML 预处理,并保留后渲染归一化入口。
|
||||
///
|
||||
/// 预处理管线由各 stage 文件持有实现,本文件只作为公开入口协调调用。
|
||||
/// `RDEPUBDTCoreTextRenderer` 直接调用各 stage 的静态方法。
|
||||
enum RDEPUBTextRendererSupport {
|
||||
|
||||
// MARK: - 预处理管线入口
|
||||
|
||||
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
|
||||
///
|
||||
/// 内部流程:
|
||||
/// 1. HTMLNormalizer.normalizeHTML
|
||||
/// 2. SemanticMarkerInjector.injectPaginationSemanticMarkers
|
||||
/// 3. DiagnosticsCollector.inlineLinkedStyleSheets
|
||||
/// 4. StyleSheetComposer.makeStyleSheetLayers + injectStyleTag
|
||||
/// 5. FontNormalizer.registerEmbeddedFonts
|
||||
/// 6. HTMLNormalizer.injectBaseHref
|
||||
/// 7. FragmentMarkerInjector.injectFragmentMarkers
|
||||
/// 8. DiagnosticsCollector.collectImageDiagnostics
|
||||
static func makeChapterRenderRequest(
|
||||
href: String,
|
||||
title: String,
|
||||
rawHTML: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
resourceResolver: RDEPUBResourceResolver?,
|
||||
contentLanguageCode: String? = nil,
|
||||
pageSize: CGSize? = nil,
|
||||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
||||
) -> RDEPUBTextChapterRenderRequest {
|
||||
let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
|
||||
into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
|
||||
)
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: normalizedHTML,
|
||||
chapterHref: href,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
|
||||
style: style,
|
||||
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
contentLanguageCode: contentLanguageCode,
|
||||
sourceHTML: rawHTML
|
||||
)
|
||||
RDEPUBFontNormalizer.registerEmbeddedFonts(
|
||||
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
|
||||
chapterHref: href,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||
let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithBase,
|
||||
styleID: "rd-native-default-replace-dark",
|
||||
css: layers
|
||||
.filter { $0.kind != .user && $0.kind != .epub }
|
||||
.map(\.css)
|
||||
.joined(separator: "\n\n"),
|
||||
position: .headStart
|
||||
)
|
||||
let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithDefaultLayers,
|
||||
styleID: "rd-native-epub",
|
||||
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
|
||||
into: htmlWithEPUBLayer,
|
||||
styleID: "rd-native-user",
|
||||
css: layers.first(where: { $0.kind == .user })?.css ?? "",
|
||||
position: .headEnd
|
||||
)
|
||||
let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
|
||||
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
|
||||
in: markedHTML,
|
||||
chapterHref: href,
|
||||
baseURL: baseURL,
|
||||
resourceResolver: resourceResolver
|
||||
)
|
||||
|
||||
let context = RDEPUBTextChapterContext(
|
||||
href: href,
|
||||
title: title,
|
||||
html: markedHTML,
|
||||
baseURL: baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
||||
resourceDiagnostics: resourceDiagnostics
|
||||
)
|
||||
return RDEPUBTextChapterRenderRequest(
|
||||
context: context,
|
||||
style: style,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用)
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
let sourceText = attributedString.string as NSString
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
if let textColor = style.textColor {
|
||||
updatedAttributes[.foregroundColor] = textColor
|
||||
}
|
||||
RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
|
||||
let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
|
||||
.flatMap(NSRangeFromString)
|
||||
.flatMap { $0.length > 0 ? $0 : nil }
|
||||
let paragraphRange = sourceText.length > 0
|
||||
? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
|
||||
: range
|
||||
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
|
||||
updatedAttributes[.rdPageBlockIndex] = blockIndex
|
||||
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
|
||||
}
|
||||
if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
|
||||
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
|
||||
}
|
||||
if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
|
||||
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
|
||||
}
|
||||
if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
|
||||
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
blockIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
|
||||
.foregroundColor: style.textColor ?? UIColor.black
|
||||
]
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
/// 构建共享段落样式。
|
||||
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBTypesettingInput {
|
||||
var href: String
|
||||
var title: String
|
||||
var rawHTML: String
|
||||
var baseURL: URL?
|
||||
var style: RDEPUBTextRenderStyle
|
||||
var resourceResolver: RDEPUBResourceResolver?
|
||||
var contentLanguageCode: String?
|
||||
var pageSize: CGSize?
|
||||
var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
}
|
||||
|
||||
struct RDEPUBTypesettingOutput {
|
||||
var request: RDEPUBTextChapterRenderRequest
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
}
|
||||
|
||||
protocol RDEPUBTypesettingStage {
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
|
||||
}
|
||||
|
||||
struct RDEPUBTextTypesetterPipeline {
|
||||
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
|
||||
let htmlNormalizer = RDEPUBHTMLNormalizer()
|
||||
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
|
||||
let styleSheetComposer = RDEPUBStyleSheetComposer()
|
||||
let fontNormalizer = RDEPUBFontNormalizer()
|
||||
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
|
||||
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
|
||||
|
||||
let normalizedHTML = semanticMarkerInjector.process(
|
||||
htmlNormalizer.process(input.rawHTML, context: input),
|
||||
context: input
|
||||
)
|
||||
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
|
||||
fontNormalizer.registerEmbeddedFonts(
|
||||
html: normalizedHTML,
|
||||
inlinedCSS: styleSheetComposition.inlinedCSS,
|
||||
input: input
|
||||
)
|
||||
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
|
||||
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
|
||||
|
||||
let context = RDEPUBTextChapterContext(
|
||||
href: input.href,
|
||||
title: input.title,
|
||||
html: markedHTML,
|
||||
baseURL: input.baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
|
||||
resourceDiagnostics: diagnostics
|
||||
)
|
||||
let request = RDEPUBTextChapterRenderRequest(
|
||||
context: context,
|
||||
style: input.style,
|
||||
pageSize: input.pageSize,
|
||||
layoutConfig: input.layoutConfig
|
||||
)
|
||||
return RDEPUBTypesettingOutput(
|
||||
request: request,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
guard readerView.currentPage >= 0,
|
||||
activePages.indices.contains(readerView.currentPage) else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentPage = activePages[readerView.currentPage]
|
||||
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
|
||||
return
|
||||
}
|
||||
|
||||
persist(location: location)
|
||||
readingSession?.updateReadingContext(
|
||||
pageNumber: readerView.currentPage + 1,
|
||||
location: location,
|
||||
spineIndex: spineIndex,
|
||||
chapterIndex: currentPage.chapterIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
if let selection {
|
||||
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
||||
} else {
|
||||
updateCurrentSelection(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
handleSelectionMenuAction(action, selection: currentSelection)
|
||||
}
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
guard let readingSession,
|
||||
let pageNumber = readingSession.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: fromSpineIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) else {
|
||||
return
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||
}
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||
print("EPUB JS Error: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||||
guard let selection else {
|
||||
updateCurrentSelection(nil)
|
||||
return
|
||||
}
|
||||
updateCurrentSelection(normalizedTextSelection(selection))
|
||||
}
|
||||
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
handleSelectionMenuAction(action, selection: currentSelection)
|
||||
contentView.clearSelection()
|
||||
}
|
||||
|
||||
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
|
||||
guard let textBook,
|
||||
let chapterData = textBook.chapterData(for: selection.location.href) else {
|
||||
return scopedSelection(selection, relativeToSpineIndex: nil)
|
||||
}
|
||||
guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else {
|
||||
return scopedSelection(selection, relativeToSpineIndex: nil)
|
||||
}
|
||||
|
||||
let contentLength = max(chapterData.attributedContent.length, 1)
|
||||
let lastInclusiveOffset = max(contentLength - 1, 1)
|
||||
let start = max(0, min(payload.start, lastInclusiveOffset))
|
||||
let endExclusive = max(start + 1, min(payload.end, contentLength))
|
||||
let absoluteRange = NSRange(location: start, length: endExclusive - start)
|
||||
let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
location: location,
|
||||
text: selection.text,
|
||||
rangeInfo: selection.rangeInfo,
|
||||
createdAt: selection.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let textBook, let publication {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
return page + 1
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
return textBook.pageNumber(
|
||||
for: normalizedLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
return readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
guard let textBook,
|
||||
let publication,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||||
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
|
||||
guard let location else { return nil }
|
||||
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
}
|
||||
|
||||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
readingSession?.transition(to: .idle)
|
||||
return
|
||||
}
|
||||
|
||||
readingSession?.updateReadingContext(
|
||||
pageNumber: pageNumber,
|
||||
location: location,
|
||||
spineIndex: page.spineIndex,
|
||||
chapterIndex: page.chapterIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
|
||||
let chapters = textBook.chapterInfos
|
||||
let pages = textBook.pages.map {
|
||||
EPUBPage(
|
||||
spineIndex: $0.spineIndex,
|
||||
chapterIndex: $0.chapterIndex,
|
||||
pageIndexInChapter: $0.pageIndexInChapter,
|
||||
totalPagesInChapter: $0.totalPagesInChapter,
|
||||
chapterTitle: $0.chapterTitle,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
contentView.configure(
|
||||
page: page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: textBook.pages.count,
|
||||
configuration: configuration,
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
guard let publication,
|
||||
let request = request(for: pageNum) else {
|
||||
return containerView ?? UIView()
|
||||
}
|
||||
|
||||
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
|
||||
contentView.delegate = self
|
||||
contentView.configure(
|
||||
publication: publication,
|
||||
request: request,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: activePages.count,
|
||||
theme: configuration.theme
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
textBook == nil
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
return chapterData.highlights(on: page, from: activeHighlights)
|
||||
}
|
||||
|
||||
guard let publication else {
|
||||
return activeHighlights.filter { $0.location.href == page.href }
|
||||
}
|
||||
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
|
||||
return activeHighlights.filter {
|
||||
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
|
||||
}
|
||||
}
|
||||
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
readerContext.textChapterData(forNormalizedHref: href)
|
||||
}
|
||||
|
||||
public func topToolView(readerView: RDReaderView) -> UIView? {
|
||||
topToolView
|
||||
}
|
||||
|
||||
public func bottomToolView(readerView: RDReaderView) -> UIView? {
|
||||
bottomToolView
|
||||
}
|
||||
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||
if totalPages > 0, pageNum == totalPages - 1 {
|
||||
delegate?.epubReaderDidReachEnd(self)
|
||||
}
|
||||
|
||||
if textBook != nil,
|
||||
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
|
||||
persist(location: location)
|
||||
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
|
||||
return
|
||||
}
|
||||
|
||||
if let location = fallbackLocation(for: pageNum) {
|
||||
persist(location: location)
|
||||
}
|
||||
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
|
||||
readingSession?.transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||||
_ = isLandscape
|
||||
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
|
||||
}
|
||||
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil,
|
||||
!isRepaginating,
|
||||
!isReconcilingTextPaginationSize,
|
||||
pageNum >= 0,
|
||||
let lastTextPaginationPageSize else {
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
guard resolvedSize.width > 0,
|
||||
resolvedSize.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|
||||
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
|
||||
guard sizeChanged else {
|
||||
return
|
||||
}
|
||||
|
||||
isReconcilingTextPaginationSize = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isReconcilingTextPaginationSize = false
|
||||
guard self.textBook != nil, !self.isRepaginating else { return }
|
||||
self.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Public Reader Commands
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 重新加载书籍,重置所有状态并重新解析 EPUB
|
||||
public func reloadBook() {
|
||||
runtime.reloadBook()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameter location: 目标阅读位置
|
||||
public func go(to location: RDEPUBLocation) {
|
||||
guard publication != nil else { return }
|
||||
_ = runtime.go(to: location)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
runtime.go(toPageNumber: pageNumber, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除当前文本选中状态
|
||||
public func clearSelection() {
|
||||
runtime.clearSelection()
|
||||
}
|
||||
|
||||
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
runtime.bookmark(withID: id)
|
||||
}
|
||||
|
||||
public func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
runtime.highlight(withID: id)
|
||||
}
|
||||
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let metadata = page.metadata
|
||||
var parts = [
|
||||
"page \(page.absolutePageIndex + 1)",
|
||||
"break \(metadata.breakReason.rawValue)",
|
||||
metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
|
||||
].compactMap { $0 }
|
||||
if let firstDiagnostic = metadata.diagnostics.first {
|
||||
parts.append(firstDiagnostic)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 添加高亮标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - color: 高亮颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
runtime.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
runtime.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
runtime.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
runtime.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
public func removeAllHighlights() {
|
||||
runtime.removeAllHighlights()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
|
||||
guard publication != nil else { return false }
|
||||
|
||||
let components = href.components(separatedBy: "#")
|
||||
let baseHref = components.first ?? href
|
||||
let fragment = components.count > 1 ? components[1] : nil
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: baseHref,
|
||||
progression: 0,
|
||||
fragment: fragment
|
||||
)
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
runtime.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 执行全文搜索,自动跳转到第一个匹配项
|
||||
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
|
||||
public func search(keyword: String) {
|
||||
runtime.search(keyword: keyword)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func searchNext() -> Bool {
|
||||
runtime.searchNext()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func searchPrevious() -> Bool {
|
||||
runtime.searchPrevious()
|
||||
}
|
||||
|
||||
public func clearSearch() {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
readerContext.currentLayoutContext()
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
readerContext.currentPreferences()
|
||||
}
|
||||
|
||||
func currentTextPageSize() -> CGSize {
|
||||
readerContext.currentTextPageSize()
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
readerContext.currentTextRenderStyle()
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
readerContext.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
readerContext.resolvedTextRenderer()
|
||||
}
|
||||
|
||||
func ensurePaginationHostView() -> UIView {
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
|
||||
if paginationHostView.superview == nil {
|
||||
view.addSubview(paginationHostView)
|
||||
view.sendSubviewToBack(paginationHostView)
|
||||
}
|
||||
paginationHostView.frame = hostFrame
|
||||
return paginationHostView
|
||||
}
|
||||
|
||||
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
|
||||
guard let publication, activePages.indices.contains(pageIndex) else {
|
||||
return nil
|
||||
}
|
||||
let page = activePages[pageIndex]
|
||||
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
|
||||
return currentPreferences().renderRequest(
|
||||
for: page,
|
||||
publication: publication,
|
||||
viewportSize: currentLayoutContext().viewportSize,
|
||||
targetLocation: pendingLocation,
|
||||
highlights: highlights(for: page),
|
||||
searchPresentation: searchPresentation(for: page)
|
||||
)
|
||||
}
|
||||
|
||||
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
|
||||
guard activePages.indices.contains(pageIndex) else { return nil }
|
||||
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
|
||||
}
|
||||
|
||||
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
|
||||
guard let publication else { return [] }
|
||||
if let spread = page.fixedSpread {
|
||||
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
|
||||
return activeHighlights.filter { highlight in
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||||
return false
|
||||
}
|
||||
return hrefs.contains(normalizedHref)
|
||||
}
|
||||
}
|
||||
|
||||
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
|
||||
let href = publication.spine[page.spineIndex].href
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href)
|
||||
return activeHighlights.filter { highlight in
|
||||
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,234 @@
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
func applyReaderViewConfiguration() {
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|
||||
|| readerView.pageDirection != resolvedDirection
|
||||
let preservedLocation = presentationDidChange
|
||||
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
||||
: nil
|
||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
readerView.pageDirection = resolvedDirection
|
||||
updateReaderChrome()
|
||||
if presentationDidChange {
|
||||
readerView.switchReaderDisplayType(configuration.displayType)
|
||||
if let preservedLocation {
|
||||
_ = restoreReadingLocation(preservedLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
runtime.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
runtime.loadPublication()
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
runtime.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
runtime.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
runtime.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
runtime.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
readerContext.persistenceLocation()
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
readerContext.persist(location: location)
|
||||
}
|
||||
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
runtime.annotationCoordinator.updateCurrentSelection(selection)
|
||||
}
|
||||
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
) -> RDEPUBSelection? {
|
||||
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
runtime.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
runtime.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
runtime.updateReaderChrome()
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
runtime.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
runtime.presentBookmarksManager()
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
runtime.presentHighlightsManager()
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
runtime.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
runtime.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
runtime.presentSettings()
|
||||
}
|
||||
|
||||
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
|
||||
var nextConfiguration = configuration
|
||||
update(&nextConfiguration)
|
||||
configuration = nextConfiguration
|
||||
}
|
||||
|
||||
func setScreenBrightness(_ brightness: CGFloat) {
|
||||
currentBrightness = max(0, min(1, brightness))
|
||||
persistReaderSettingsIfNeeded()
|
||||
}
|
||||
|
||||
func persistReaderSettingsIfNeeded() {
|
||||
let settings = RDEPUBReaderSettings.capture(
|
||||
configuration: configuration,
|
||||
brightness: currentBrightness
|
||||
)
|
||||
persistence?.saveReaderSettings(settings)
|
||||
}
|
||||
|
||||
func requiresRepagination(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
) -> Bool {
|
||||
oldConfiguration.fontSize != newConfiguration.fontSize ||
|
||||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
|
||||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
|
||||
oldConfiguration.columnGap != newConfiguration.columnGap ||
|
||||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
|
||||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
|
||||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
|
||||
oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode ||
|
||||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
|
||||
}
|
||||
|
||||
func requiresVisibleRefresh(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
) -> Bool {
|
||||
oldConfiguration.theme != newConfiguration.theme
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
runtime.presentTableOfContents()
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
print("[Debug] handleBackAction called")
|
||||
runtime.handleBackAction()
|
||||
}
|
||||
|
||||
func handle(error: Error) {
|
||||
isRepaginating = false
|
||||
hideLoading()
|
||||
readerContext.clearActiveSnapshot()
|
||||
textBook = nil
|
||||
readerView.reloadData()
|
||||
errorLabel.text = error.localizedDescription
|
||||
errorLabel.isHidden = false
|
||||
delegate?.epubReader(self, didFailWithError: error)
|
||||
}
|
||||
|
||||
func showLoading() {
|
||||
errorLabel.isHidden = true
|
||||
loadingIndicator.startAnimating()
|
||||
}
|
||||
|
||||
func hideLoading() {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
runtime.currentViewportSignature()
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
runtime.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
private func resolvedPageDirection() -> RDReaderView.PageDirection {
|
||||
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContents
|
||||
guard !items.isEmpty else { return nil }
|
||||
|
||||
let currentPageNumber = max(readerView.currentPage + 1, 1)
|
||||
let pageAnchoredMatch = items.last { item in
|
||||
guard let pageNumber = item.pageNumber else { return false }
|
||||
return pageNumber <= currentPageNumber
|
||||
}
|
||||
if let pageAnchoredMatch {
|
||||
return pageAnchoredMatch
|
||||
}
|
||||
|
||||
guard let publication,
|
||||
let currentLocation = currentVisibleLocation(),
|
||||
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(
|
||||
for: currentLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
),
|
||||
let tocItem = chapterData.primaryTableOfContentsItem(
|
||||
from: publication.tableOfContents,
|
||||
normalizer: { publication.resourceResolver.normalizedHref($0) }
|
||||
) {
|
||||
return items.last { $0.href == tocItem.href } ?? items.last {
|
||||
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
|
||||
return false
|
||||
}
|
||||
return normalizedItemHref == normalizedCurrentHref
|
||||
}
|
||||
}
|
||||
|
||||
return items.last { item in
|
||||
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
|
||||
return false
|
||||
}
|
||||
return normalizedItemHref == normalizedCurrentHref
|
||||
}
|
||||
}
|
||||
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0
|
||||
) -> [RDEPUBReaderTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||
let pageNumber: Int?
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
pageNumber = chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
} else if let readingSession {
|
||||
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
} else {
|
||||
pageNumber = nil
|
||||
}
|
||||
|
||||
let current = RDEPUBReaderTableOfContentsItem(
|
||||
title: item.title,
|
||||
href: item.href,
|
||||
depth: depth,
|
||||
pageNumber: pageNumber
|
||||
)
|
||||
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -100,6 +100,7 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
print("[Debug] backAction fired, onBack: \(String(describing: onBack))")
|
||||
onBack?()
|
||||
}
|
||||
|
||||
|
||||
@ -1,752 +0,0 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
// MARK: - 可选中文本视图
|
||||
|
||||
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
/// 选择菜单操作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectedRange.location != NSNotFound && selectedRange.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
onSelectionAction?(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
onSelectionAction?(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
onSelectionAction?(.annotate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 直接绘制视图
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染
|
||||
#if canImport(DTCoreText)
|
||||
final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
contentMode = .redraw
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var highlightedRanges: [RDEPUBHighlight] = []
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
private var isSelectionFromInteraction = false
|
||||
private var selectionMenuAnchorRect: CGRect?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
|
||||
let view = RDEPUBDirectCoreTextPageView()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let interactionController = RDEPUBPageInteractionController()
|
||||
|
||||
private let backgroundOverlayView: RDEPUBSelectionOverlayView = {
|
||||
let view = RDEPUBSelectionOverlayView()
|
||||
return view
|
||||
}()
|
||||
|
||||
private let overlayView: RDEPUBSelectionOverlayView = {
|
||||
let view = RDEPUBSelectionOverlayView()
|
||||
return view
|
||||
}()
|
||||
|
||||
private var selectionAnchorPoint: CGPoint?
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
private let coverImageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
view.contentMode = .scaleAspectFit
|
||||
view.isHidden = true
|
||||
return view
|
||||
}()
|
||||
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(backgroundOverlayView)
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = self
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.4
|
||||
addGestureRecognizer(longPress)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return overlayView.selectionRange?.length ?? 0 > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
#else
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
|
||||
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#endif
|
||||
overlayView.frame = bounds.inset(by: contentInsets)
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
highlightedRanges = highlights
|
||||
currentSearchState = searchState
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
if configureCoverIfNeeded(for: page) {
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
}
|
||||
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
let selectionContent = normalizedPageContent(from: page)
|
||||
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
||||
selectionContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: selectionRange
|
||||
)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = normalizedPageContent(from: page)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
textView.attributedText = nil
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
#endif
|
||||
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
let (bgDecorations, fgDecorations) = buildOverlayDecorations(page: page)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
selectionAnchorPoint = nil
|
||||
selectionMenuAnchorRect = nil
|
||||
isSelectionFromInteraction = false
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
let point = gesture.location(in: overlayView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
selectionAnchorPoint = point
|
||||
isSelectionFromInteraction = true
|
||||
handleSelectionFromInteraction(point: point, anchorPoint: nil)
|
||||
|
||||
case .changed:
|
||||
guard let anchor = selectionAnchorPoint else { return }
|
||||
handleSelectionFromInteraction(point: point, anchorPoint: anchor)
|
||||
|
||||
case .ended:
|
||||
isSelectionFromInteraction = false
|
||||
showSelectionMenuIfNeeded()
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectionFromInteraction(point: CGPoint, anchorPoint: CGPoint?) {
|
||||
guard let page = currentPage else { return }
|
||||
|
||||
let range: NSRange?
|
||||
if let anchor = anchorPoint {
|
||||
range = interactionController.selectionRange(from: anchor, to: point)
|
||||
} else if let idx = interactionController.characterIndex(at: point) {
|
||||
range = NSRange(location: idx, length: 1)
|
||||
} else {
|
||||
range = nil
|
||||
}
|
||||
|
||||
guard let range else { return }
|
||||
let rects = interactionController.selectionRects(for: range)
|
||||
overlayView.updateSelection(absoluteRange: range, rects: rects)
|
||||
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
|
||||
notifySelectionChange(range: range, page: page)
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy)
|
||||
}
|
||||
|
||||
@objc private func rd_highlight(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight)
|
||||
}
|
||||
|
||||
@objc private func rd_annotate(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate)
|
||||
}
|
||||
|
||||
private func notifySelectionChange(range: NSRange, page: RDEPUBTextPage) {
|
||||
let source = page.chapterContent.string as NSString
|
||||
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let chapterLength = max(page.chapterContent.length - 1, 1)
|
||||
let chapterStart = max(range.location, 0)
|
||||
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(chapterStart) / Double(chapterLength),
|
||||
lastProgression: Double(chapterEnd) / Double(chapterLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
|
||||
)
|
||||
delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
#if canImport(DTCoreText)
|
||||
guard overlayView.selectionRange?.length ?? 0 > 0,
|
||||
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
becomeFirstResponder()
|
||||
let menuRect = overlayView.convert(anchorRect, to: self)
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(menuRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
|
||||
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
}
|
||||
|
||||
private func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for highlight in highlightedRanges where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - contentBaseOffset,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?
|
||||
) {
|
||||
applySearchHighlights(
|
||||
to: content,
|
||||
page: page,
|
||||
searchState: searchState,
|
||||
contentBaseOffset: page.pageStartOffset
|
||||
)
|
||||
}
|
||||
|
||||
private func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func buildOverlayDecorations(page: RDEPUBTextPage) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
|
||||
var background: [RDEPUBTextOverlayDecoration] = []
|
||||
var foreground: [RDEPUBTextOverlayDecoration] = []
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
// Search results → background (behind text)
|
||||
if let searchState = currentSearchState {
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||
let color = isActive ? activeColor : normalColor
|
||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||
}
|
||||
}
|
||||
|
||||
// Highlights → background (filled) or foreground (underline)
|
||||
for highlight in highlightedRanges where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
absoluteRange: absoluteRange,
|
||||
rects: rects,
|
||||
color: color
|
||||
)
|
||||
|
||||
if decoration.kind == .underline {
|
||||
foreground.append(decoration)
|
||||
} else {
|
||||
background.append(decoration)
|
||||
}
|
||||
}
|
||||
|
||||
return (background, foreground)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
let image = coverImage(from: page.content) else {
|
||||
return false
|
||||
}
|
||||
|
||||
coverImageView.image = image
|
||||
coverImageView.isHidden = false
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
return true
|
||||
}
|
||||
|
||||
private func coverImage(from content: NSAttributedString) -> UIImage? {
|
||||
guard content.length > 0 else { return nil }
|
||||
var resolvedImage: UIImage?
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||
guard let image = image(from: value) else { return }
|
||||
resolvedImage = image
|
||||
stop.pointee = true
|
||||
}
|
||||
return resolvedImage
|
||||
}
|
||||
|
||||
private func image(from attachmentValue: Any?) -> UIImage? {
|
||||
#if canImport(DTCoreText)
|
||||
if let attachment = attachmentValue as? DTTextAttachment,
|
||||
let url = attachment.contentURL {
|
||||
return UIImage(contentsOfFile: url.path)
|
||||
}
|
||||
#endif
|
||||
if let attachment = attachmentValue as? NSTextAttachment {
|
||||
if let image = attachment.image {
|
||||
return image
|
||||
}
|
||||
if let data = attachment.contents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
let data = fileWrapper.regularFileContents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
proxy.removeAttribute(.backgroundColor, range: fullRange)
|
||||
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
|
||||
|
||||
var attachmentRanges: [NSRange] = []
|
||||
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard value != nil else { return }
|
||||
attachmentRanges.append(range)
|
||||
}
|
||||
|
||||
for range in attachmentRanges.reversed() {
|
||||
let replacement = NSAttributedString(
|
||||
string: String(repeating: " ", count: max(range.length, 1)),
|
||||
attributes: [
|
||||
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
|
||||
.foregroundColor: UIColor.clear
|
||||
]
|
||||
)
|
||||
proxy.replaceCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else {
|
||||
return content
|
||||
}
|
||||
|
||||
let text = content.string as NSString
|
||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||
guard firstParagraphRange.length > 0 else {
|
||||
return content
|
||||
}
|
||||
|
||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
||||
mutableStyle.paragraphSpacingBefore = 0
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
||||
let pageStart = page.pageStartOffset
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else {
|
||||
return false
|
||||
}
|
||||
|
||||
let chapterText = page.chapterContent.string as NSString
|
||||
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
let displayRange = coreTextDisplayRange,
|
||||
let page = currentPage,
|
||||
coreTextContentView.bounds.width > 0,
|
||||
coreTextContentView.bounds.height > 0 else {
|
||||
interactionController.configure(layoutFrame: nil, page: currentPage)
|
||||
return
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
interactionController.configure(layoutFrame: nil, page: page)
|
||||
return
|
||||
}
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||
coreTextContentView.layoutFrame = layoutFrame
|
||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||
overlayView.updateSnapshot(interactionController.snapshot)
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
guard !isSelectionFromInteraction else { return }
|
||||
guard let page = currentPage else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
guard selectedRange.location != NSNotFound,
|
||||
selectedRange.length > 0,
|
||||
let attributedText = textView.attributedText else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let source = attributedText.string as NSString
|
||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
}
|
||||
29
Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift
Normal file
29
Sources/RDReaderView/EPUBUI/RDEPUBViewportTypes.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
struct RDEPUBViewportSignature: Equatable {
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
let safeTop: CGFloat
|
||||
let safeLeft: CGFloat
|
||||
let safeBottom: CGFloat
|
||||
let safeRight: CGFloat
|
||||
|
||||
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
|
||||
abs(width - other.width) > threshold ||
|
||||
abs(height - other.height) > threshold ||
|
||||
abs(safeTop - other.safeTop) > threshold ||
|
||||
abs(safeLeft - other.safeLeft) > threshold ||
|
||||
abs(safeBottom - other.safeBottom) > threshold ||
|
||||
abs(safeRight - other.safeRight) > threshold
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
enum RDEPUBViewportChangeReason {
|
||||
case viewLayout
|
||||
case orientationTransition
|
||||
}
|
||||
|
||||
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
@ -0,0 +1,496 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeBookmarks.first { $0.id == id }
|
||||
}
|
||||
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
controller.bottomToolView.setAddHighlightEnabled(
|
||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||||
)
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
let sourceSelection = selection ?? controller.currentSelection
|
||||
guard let sourceSelection,
|
||||
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
|
||||
!scopedSelection.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let newHighlight = RDEPUBHighlight(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: scopedSelection.location,
|
||||
text: scopedSelection.text,
|
||||
rangeInfo: scopedSelection.rangeInfo,
|
||||
style: style,
|
||||
color: color,
|
||||
note: note
|
||||
)
|
||||
|
||||
let isDuplicate = controller.activeHighlights.contains { highlight in
|
||||
highlight.location.href == newHighlight.location.href &&
|
||||
highlight.location.fragment == newHighlight.location.fragment &&
|
||||
highlight.text == newHighlight.text &&
|
||||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||||
highlight.style == newHighlight.style
|
||||
}
|
||||
guard !isDuplicate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
controller.activeHighlights.append(newHighlight)
|
||||
persistHighlightsAndRefreshContent()
|
||||
updateCurrentSelection(nil)
|
||||
return newHighlight
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let scopedHighlight = scopedHighlight(highlight) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
|
||||
controller.activeHighlights[index] = scopedHighlight
|
||||
} else {
|
||||
controller.activeHighlights.append(scopedHighlight)
|
||||
}
|
||||
persistHighlightsAndRefreshContent()
|
||||
return scopedHighlight
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
let removed = controller.activeHighlights.remove(at: index)
|
||||
persistHighlightsAndRefreshContent()
|
||||
return removed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
controller.activeHighlights[index].note = normalizedNote(note)
|
||||
persistHighlightsAndRefreshContent()
|
||||
return controller.activeHighlights[index]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return controller.restoreReadingLocation(highlight.location, animated: animated)
|
||||
}
|
||||
|
||||
func removeAllHighlights() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
controller.activeHighlights.removeAll()
|
||||
persistHighlightsAndRefreshContent()
|
||||
}
|
||||
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
) -> RDEPUBSelection? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
selection.location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: selection.location.href,
|
||||
progression: selection.location.progression,
|
||||
lastProgression: selection.location.lastProgression,
|
||||
fragment: selection.location.fragment,
|
||||
rangeAnchor: selection.location.rangeAnchor
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: normalizedLocation,
|
||||
text: selection.text,
|
||||
rangeInfo: selection.rangeInfo,
|
||||
createdAt: selection.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
|
||||
let highlightsController = RDEPUBReaderHighlightsViewController(
|
||||
highlights: controller.activeHighlights,
|
||||
theme: controller.configuration.theme,
|
||||
sectionTitleProvider: { [weak self] highlight in
|
||||
self?.titleForHighlight(highlight)
|
||||
}
|
||||
)
|
||||
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
||||
guard let controller = self?.controller else { return }
|
||||
highlightsController?.dismiss(animated: true) {
|
||||
controller.go(to: highlight.location)
|
||||
}
|
||||
}
|
||||
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
||||
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
|
||||
}
|
||||
highlightsController.onDeleteHighlight = { [weak self] highlight in
|
||||
_ = self?.controller?.removeHighlight(id: highlight.id)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: highlightsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights,
|
||||
let currentSelection = controller.currentSelection else {
|
||||
return
|
||||
}
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
guard let selection else { return }
|
||||
switch action {
|
||||
case .copy:
|
||||
UIPasteboard.general.string = selection.text
|
||||
updateCurrentSelection(nil)
|
||||
case .highlight:
|
||||
createAnnotation(from: selection, style: .highlight)
|
||||
case .annotate:
|
||||
presentAnnotationNoteEditor(for: selection)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
guard bookmark(matching: location) == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let newBookmark = RDEPUBBookmark(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: location,
|
||||
chapterTitle: titleForBookmarkLocation(location),
|
||||
note: normalizedBookmarkNote(note)
|
||||
)
|
||||
controller.activeBookmarks.append(newBookmark)
|
||||
persistBookmarksAndRefreshChrome()
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let existingBookmark = bookmark(matching: location) {
|
||||
_ = removeBookmark(id: existingBookmark.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
return addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
let removed = controller.activeBookmarks.remove(at: index)
|
||||
persistBookmarksAndRefreshChrome()
|
||||
return removed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let bookmark = bookmark(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
|
||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
|
||||
let bookmarksController = RDEPUBReaderBookmarksViewController(
|
||||
bookmarks: controller.activeBookmarks,
|
||||
theme: controller.configuration.theme
|
||||
)
|
||||
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
|
||||
guard let controller = self?.controller else { return }
|
||||
bookmarksController?.dismiss(animated: true) {
|
||||
_ = controller.restoreReadingLocation(bookmark.location, animated: true)
|
||||
}
|
||||
}
|
||||
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
|
||||
_ = self?.controller?.removeBookmark(id: bookmark.id)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: bookmarksController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
highlight.location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: highlight.location.href,
|
||||
progression: highlight.location.progression,
|
||||
lastProgression: highlight.location.lastProgression,
|
||||
fragment: highlight.location.fragment,
|
||||
rangeAnchor: highlight.location.rangeAnchor
|
||||
)
|
||||
return RDEPUBHighlight(
|
||||
id: highlight.id,
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: normalizedLocation,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
style: highlight.style,
|
||||
color: highlight.color,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
private func persistHighlightsAndRefreshContent() {
|
||||
guard let controller else { return }
|
||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
|
||||
self?.createAnnotation(from: selection, style: .highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
||||
self?.createAnnotation(from: selection, style: .underline)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
|
||||
self?.presentAnnotationNoteEditor(for: selection)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = controller.bottomToolView
|
||||
popover.sourceRect = controller.bottomToolView.bounds
|
||||
}
|
||||
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
||||
_ = addAnnotation(from: selection, style: style, note: note)
|
||||
}
|
||||
|
||||
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "输入批注内容"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||||
self?.createAnnotation(
|
||||
from: selection,
|
||||
style: .highlight,
|
||||
note: alert?.textFields?.first?.text
|
||||
)
|
||||
})
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication,
|
||||
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return controller.flattenedTableOfContents.first { item in
|
||||
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
|
||||
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
|
||||
}?.title
|
||||
}
|
||||
|
||||
private func normalizedNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
|
||||
guard let controller else { return nil }
|
||||
if let currentLocation = controller.currentVisibleLocation(),
|
||||
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
|
||||
return controller.currentTableOfContentsItem?.title
|
||||
}
|
||||
|
||||
return controller.flattenedTableOfContents.last { item in
|
||||
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
|
||||
}?.title
|
||||
}
|
||||
|
||||
private func persistBookmarksAndRefreshChrome() {
|
||||
guard let controller else { return }
|
||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
|
||||
updateBookmarkChrome()
|
||||
}
|
||||
|
||||
private func currentBookmark() -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
return bookmark(matching: location)
|
||||
}
|
||||
|
||||
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location else { return nil }
|
||||
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
|
||||
}
|
||||
|
||||
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||||
let locationAnchor = location.rangeAnchor {
|
||||
return bookmarkAnchor == locationAnchor
|
||||
}
|
||||
|
||||
if let bookmarkFragment = bookmark.location.fragment,
|
||||
let locationFragment = location.fragment {
|
||||
return bookmarkFragment == locationFragment
|
||||
}
|
||||
|
||||
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
|
||||
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
|
||||
return progressionDelta <= threshold
|
||||
}
|
||||
|
||||
private func bookmarkHref(for location: RDEPUBLocation) -> String {
|
||||
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
|
||||
}
|
||||
|
||||
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
|
||||
let rawHref = href.components(separatedBy: "#").first ?? href
|
||||
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
||||
}
|
||||
|
||||
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
|
||||
guard let controller else { return nil }
|
||||
guard let location else { return nil }
|
||||
guard let publication = controller.publication else {
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedBookmarkNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
|
||||
setupReaderView(readerView, in: controller.view)
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
}
|
||||
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
|
||||
let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
|
||||
if let id = context.currentBookIdentifier {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
readerView.dataSource = context.controller as? RDReaderDataSource
|
||||
readerView.delegate = context.controller as? RDReaderDelegate
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
NSLayoutConstraint.activate([
|
||||
readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
|
||||
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
|
||||
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
|
||||
context.controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(loadingIndicator)
|
||||
NSLayoutConstraint.activate([
|
||||
loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
|
||||
errorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(errorLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
|
||||
errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
|
||||
errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderChromeCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
print("[Debug] onBack fired, controller: \(String(describing: self?.controller))")
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
_ = self?.context.runtime?.toggleBookmark()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
self?.presentTableOfContents()
|
||||
}
|
||||
toolView.onShowBookmarks = { [weak self] in
|
||||
self?.context.runtime?.presentBookmarksManager()
|
||||
}
|
||||
toolView.onShowHighlights = { [weak self] in
|
||||
self?.context.runtime?.presentHighlightsManager()
|
||||
}
|
||||
toolView.onAddHighlight = { [weak self] in
|
||||
self?.context.runtime?.presentAnnotationCreation()
|
||||
}
|
||||
toolView.onShowSettings = { [weak self] in
|
||||
self?.presentSettings()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
controller.topToolView.setTitle(
|
||||
controller.title
|
||||
?? controller.parser?.metadata.title
|
||||
?? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
)
|
||||
controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil)
|
||||
controller.bottomToolView.apply(theme: controller.configuration.theme)
|
||||
controller.bottomToolView.updateVisibility(
|
||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||
allowsHighlights: controller.configuration.allowsHighlights,
|
||||
showsSettingsPanel: controller.configuration.showsSettingsPanel
|
||||
)
|
||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||||
controller.bottomToolView.setAddHighlightEnabled(
|
||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||||
)
|
||||
controller.bottomToolView.setHighlightsEnabled(
|
||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
||||
)
|
||||
controller.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
let settingsController = RDEPUBReaderSettingsViewController(
|
||||
configuration: controller.configuration,
|
||||
brightness: controller.currentBrightness
|
||||
)
|
||||
settingsController.onBrightnessChange = { [weak controller] brightness in
|
||||
controller?.setScreenBrightness(brightness)
|
||||
}
|
||||
settingsController.onFontSizeChange = { [weak controller] fontSize in
|
||||
controller?.updateConfiguration { $0.fontSize = fontSize }
|
||||
}
|
||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
||||
}
|
||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||
controller?.updateConfiguration { $0.displayType = displayType }
|
||||
}
|
||||
settingsController.onThemeChange = { [weak controller] theme in
|
||||
controller?.updateConfiguration { $0.theme = theme }
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
let items = controller.flattenedTableOfContents
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let chapterController = RDEPUBReaderChapterListController(
|
||||
items: items,
|
||||
currentItem: controller.currentTableOfContentsItem,
|
||||
theme: controller.configuration.theme
|
||||
)
|
||||
chapterController.onSelectItem = { [weak controller] item in
|
||||
guard let controller else { return }
|
||||
chapterController.dismiss(animated: true) {
|
||||
_ = controller.go(toTableOfContentsItem: item, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: chapterController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
}
|
||||
|
||||
private func close(_ controller: UIViewController) {
|
||||
let target = closestDismissTarget(from: controller)
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.viewControllers.first !== target {
|
||||
navigationController.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.presentingViewController != nil {
|
||||
navigationController.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if target.presentingViewController != nil {
|
||||
target.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
controller.dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
|
||||
var candidate: UIViewController = controller
|
||||
var current = controller.parent
|
||||
while let parent = current {
|
||||
if let navigationController = parent.navigationController,
|
||||
navigationController.viewControllers.contains(parent) {
|
||||
return parent
|
||||
}
|
||||
if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
|
||||
return parent
|
||||
}
|
||||
candidate = parent
|
||||
current = parent.parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
import UIKit
|
||||
|
||||
/// 阅读器共享状态中心:所有 coordinator 通过 context 访问业务状态和便捷方法。
|
||||
///
|
||||
/// context 持有:
|
||||
/// - 业务状态(parser、publication、textBook、pages 等)
|
||||
/// - UI 配置(configuration、brightness)
|
||||
/// - 持久化策略(persistence)
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
// MARK: - 引用
|
||||
|
||||
weak var controller: RDEPUBReaderController?
|
||||
weak var readerView: RDReaderView?
|
||||
var dependencies: RDEPUBReaderDependencies = .live
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
// MARK: - 业务状态
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
var publication: RDEPUBPublication?
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
var textBook: RDEPUBTextBook?
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
var currentBookIdentifier: String?
|
||||
var paginationToken = UUID()
|
||||
var paginator: RDEPUBPaginator?
|
||||
var searchState: RDEPUBSearchState?
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
var currentSelection: RDEPUBSelection?
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
var isRepaginating: Bool = false
|
||||
var didStartInitialLoad: Bool = false
|
||||
var isExternalTextBook: Bool = false
|
||||
var textFileURL: URL?
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
self.readerView = controller.readerView
|
||||
}
|
||||
|
||||
// MARK: - 便捷方法
|
||||
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
let containerSize = readerView?.bounds.size ?? .zero
|
||||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||||
let resolvedSize = containerSize == .zero ? viewSize : containerSize
|
||||
return RDEPUBNavigatorLayoutContext(
|
||||
containerSize: resolvedSize,
|
||||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||||
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
|
||||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||||
reflowableContentInsets: configuration.reflowableContentInsets
|
||||
)
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
}
|
||||
|
||||
func currentTextPageSize() -> CGSize {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||||
return resolvedSize
|
||||
}
|
||||
}
|
||||
return currentLayoutContext().viewportSize
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
frameHeight: max(pageSize.height, 1),
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85,
|
||||
fallbackViewportSize: dependencies.environment.fallbackViewportSize
|
||||
)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { dependencies.environment.currentBrightness }
|
||||
set { dependencies.environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
controller?.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let currentBookIdentifier else { return nil }
|
||||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let currentBookIdentifier else { return }
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||||
return textBook.chapters.lazy
|
||||
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
func showLoading() {
|
||||
controller?.showLoading()
|
||||
}
|
||||
|
||||
func hideLoading() {
|
||||
controller?.hideLoading()
|
||||
}
|
||||
|
||||
func handle(error: Error) {
|
||||
controller?.handle(error: error)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
controller?.updateReaderChrome()
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
controller?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
controller?.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
controller?.updateBookmarkChrome()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import UIKit
|
||||
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
var currentBrightness: CGFloat { get set }
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
public var currentBrightness: CGFloat {
|
||||
get { CGFloat(UIScreen.main.brightness) }
|
||||
set { UIScreen.main.brightness = newValue }
|
||||
}
|
||||
|
||||
public var fallbackViewportSize: CGSize {
|
||||
UIScreen.main.bounds.size
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderDependencies {
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
makePaginator: @escaping () -> RDEPUBPaginator,
|
||||
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
||||
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
|
||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
) {
|
||||
self.environment = environment
|
||||
self.makeParser = makeParser
|
||||
self.makePaginator = makePaginator
|
||||
self.makeTextBookBuilder = makeTextBookBuilder
|
||||
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
}
|
||||
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
makeParser: { RDEPUBParser() },
|
||||
makePaginator: { RDEPUBPaginator() },
|
||||
makeTextBookBuilder: { renderer, cache, layoutConfig in
|
||||
RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
|
||||
},
|
||||
makePlainTextBookBuilder: { renderer, layoutConfig in
|
||||
RDPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
|
||||
},
|
||||
makeTextRenderer: { engine in
|
||||
switch engine {
|
||||
case .dtCoreText:
|
||||
return RDEPUBDTCoreTextRenderer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
!controller.didStartInitialLoad,
|
||||
readerView.bounds.width > 0,
|
||||
readerView.bounds.height > 0 else {
|
||||
return
|
||||
}
|
||||
controller.didStartInitialLoad = true
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
let loadToken = UUID()
|
||||
context.paginationToken = loadToken
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
let parser = self.context.makeParser()
|
||||
|
||||
do {
|
||||
try parser.parse(epubURL: controller.epubURL)
|
||||
let publication = parser.makePublication()
|
||||
let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
|
||||
let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
|
||||
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
|
||||
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.runtime?.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.parser = parser
|
||||
context.publication = publication
|
||||
context.currentBookIdentifier = bookIdentifier
|
||||
context.activeBookmarks = bookmarks
|
||||
context.activeHighlights = highlights
|
||||
context.readingSession = RDEPUBReadingSession(publication: publication)
|
||||
context.readingSession?.transition(to: .loading)
|
||||
controller.title = parser.metadata.title.isEmpty
|
||||
? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
: parser.metadata.title
|
||||
controller.applyReaderViewConfiguration()
|
||||
context.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didOpen: publication)
|
||||
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return false }
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
)
|
||||
} else {
|
||||
context.readingSession?.transition(to: .jumping)
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
return true
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
return nil
|
||||
}
|
||||
if context.textBook != nil, readerView.currentPage >= 0 {
|
||||
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
|
||||
}
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
return nil
|
||||
}
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateLocation: location)
|
||||
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
|
||||
controller.updateBookmarkChrome()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
let publication = context.publication,
|
||||
let readingSession = context.readingSession else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.isRepaginating = true
|
||||
controller.errorLabel.isHidden = true
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
return
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
hostingView: controller.ensurePaginationHostView(),
|
||||
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
|
||||
) { [weak controller] pageCounts in
|
||||
guard let controller, self.context.paginationToken == token else { return }
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: pageCounts,
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
self.context.paginator = nil
|
||||
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !textBook.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
controller.isRepaginating = false
|
||||
controller.hideLoading()
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
?? context.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
readerView.reloadData()
|
||||
if let restoreLocation {
|
||||
_ = context.restoreReadingLocation(restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
let style = controller.currentTextRenderStyle()
|
||||
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
|
||||
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderRuntime {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
context.parser = nil
|
||||
context.publication = nil
|
||||
context.clearActiveSnapshot()
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
viewportMonitor.resetForReload()
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
pageNumber > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook != nil {
|
||||
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
return locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.bookmark(withID: id)
|
||||
}
|
||||
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.highlight(withID: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
func removeAllHighlights() {
|
||||
annotationCoordinator.removeAllHighlights()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
annotationCoordinator.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
annotationCoordinator.presentBookmarksManager()
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
annotationCoordinator.presentHighlightsManager()
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
annotationCoordinator.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
loadCoordinator.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
|
||||
let matches = resolvedSearchMatches(for: normalizedKeyword)
|
||||
controller.searchState = RDEPUBSearchState(
|
||||
keyword: normalizedKeyword,
|
||||
matches: matches,
|
||||
currentMatchIndex: matches.isEmpty ? nil : 0
|
||||
)
|
||||
notifySearchStateChanged()
|
||||
|
||||
if matches.isEmpty {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
} else {
|
||||
_ = navigateToCurrentSearchMatch(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
notifySearchStateChanged()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
let publication = controller.publication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageHrefs: [String]
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
} else if publication.spine.indices.contains(page.spineIndex) {
|
||||
pageHrefs = [
|
||||
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
|
||||
?? publication.spine[page.spineIndex].href
|
||||
]
|
||||
} else {
|
||||
pageHrefs = []
|
||||
}
|
||||
|
||||
guard !pageHrefs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentMatch = searchState.currentMatch
|
||||
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
let resources = pageHrefs.map { href in
|
||||
let matchCount = searchState.matches.filter {
|
||||
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
|
||||
}.count
|
||||
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
|
||||
return RDEPUBSearchPresentationResource(
|
||||
href: href,
|
||||
matchCount: matchCount,
|
||||
activeLocalMatchIndex: activeLocalMatchIndex
|
||||
)
|
||||
}
|
||||
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
|
||||
}
|
||||
|
||||
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||
guard let controller else { return [] }
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
if let parser = controller.parser, let publication = controller.publication {
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func advanceSearch(by delta: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentIndex = searchState.currentMatchIndex ?? 0
|
||||
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
|
||||
searchState.currentMatchIndex = nextIndex
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
private func notifySearchStateChanged() {
|
||||
guard let controller else { return }
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
|
||||
}
|
||||
|
||||
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let searchMatch = controller.searchState?.currentMatch else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return false
|
||||
}
|
||||
|
||||
if let targetPageNumber = pageNumber(for: searchMatch),
|
||||
controller.readerView.currentPage == targetPageNumber - 1 {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return true
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
return controller.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let controller else { return nil }
|
||||
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||
return pageNumber
|
||||
}
|
||||
|
||||
if let rangeLocation = searchMatch.rangeLocation,
|
||||
let page = chapterData.page(containing: rangeLocation) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
return controller.readingSession?.pageIndex(
|
||||
for: location,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
|
||||
if !controller.didStartInitialLoad {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
controller.startInitialLoadIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil || controller.isExternalTextBook else {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
return
|
||||
}
|
||||
|
||||
guard !isWaitingForViewportTransitionCompletion else {
|
||||
return
|
||||
}
|
||||
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
isWaitingForViewportTransitionCompletion = true
|
||||
|
||||
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||
guard let self, let controller = self.controller else { return }
|
||||
self.isWaitingForViewportTransitionCompletion = false
|
||||
controller.view.layoutIfNeeded()
|
||||
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
|
||||
}
|
||||
}
|
||||
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
pendingPresentationRestoreLocation = nil
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleViewportChangeIfNeeded(reason: pendingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
|
||||
let insets = controller.view.safeAreaInsets
|
||||
return RDEPUBViewportSignature(
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
safeTop: insets.top,
|
||||
safeLeft: insets.left,
|
||||
safeBottom: insets.bottom,
|
||||
safeRight: insets.right
|
||||
)
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad,
|
||||
let signature = viewportSignature ?? currentViewportSignature() else {
|
||||
return
|
||||
}
|
||||
|
||||
if controller.isRepaginating {
|
||||
pendingViewportChangeReason = reason
|
||||
return
|
||||
}
|
||||
|
||||
if let lastAppliedViewportSignature,
|
||||
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
|
||||
return
|
||||
}
|
||||
|
||||
lastAppliedViewportSignature = signature
|
||||
|
||||
if controller.isExternalTextBook {
|
||||
controller.rebuildExternalTextBook()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil else { return }
|
||||
controller.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import UIKit
|
||||
|
||||
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
/// 选择菜单操作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectedRange.location != NSNotFound && selectedRange.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
onSelectionAction?(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
onSelectionAction?(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
onSelectionAction?(.annotate)
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@ struct RDEPUBTextOverlayDecoration {
|
||||
/// 文本选择和装饰的覆盖层绘制视图
|
||||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||||
final class RDEPUBSelectionOverlayView: UIView {
|
||||
class RDEPUBSelectionOverlayView: UIView {
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
private(set) var selectionRange: NSRange?
|
||||
@ -0,0 +1,136 @@
|
||||
import UIKit
|
||||
|
||||
/// 前景覆盖层,负责绘制高亮、搜索命中和当前选区。
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
func applyHighlights(
|
||||
_ highlights: [RDEPUBHighlight],
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for highlight in highlights where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - contentBaseOffset,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
|
||||
func buildDecorations(
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight],
|
||||
searchState: RDEPUBSearchState?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
|
||||
var background: [RDEPUBTextOverlayDecoration] = []
|
||||
var foreground: [RDEPUBTextOverlayDecoration] = []
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
if let searchState {
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||
let color = isActive ? activeColor : normalColor
|
||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||
}
|
||||
}
|
||||
|
||||
for highlight in highlights where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
absoluteRange: absoluteRange,
|
||||
rects: rects,
|
||||
color: color
|
||||
)
|
||||
|
||||
if decoration.kind == .underline {
|
||||
foreground.append(decoration)
|
||||
} else {
|
||||
background.append(decoration)
|
||||
}
|
||||
}
|
||||
|
||||
return (background, foreground)
|
||||
}
|
||||
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
}
|
||||
438
Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
Normal file
438
Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
Normal file
@ -0,0 +1,438 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
||||
let view = RDEPUBTextPageRenderView()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let interactionController = RDEPUBPageInteractionController()
|
||||
private let selectionController = RDEPUBTextSelectionController()
|
||||
|
||||
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
|
||||
let view = RDEPUBTextPageDecorationView()
|
||||
return view
|
||||
}()
|
||||
|
||||
private let overlayView: RDEPUBTextAnnotationOverlay = {
|
||||
let view = RDEPUBTextAnnotationOverlay()
|
||||
return view
|
||||
}()
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
private let coverImageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
view.contentMode = .scaleAspectFit
|
||||
view.isHidden = true
|
||||
return view
|
||||
}()
|
||||
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(backgroundOverlayView)
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = selectionController
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.4
|
||||
addGestureRecognizer(longPress)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectionController.canPerformSelectionAction(in: overlayView)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
#else
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
|
||||
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#endif
|
||||
overlayView.frame = bounds.inset(by: contentInsets)
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
if configureCoverIfNeeded(for: page) {
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
}
|
||||
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
let selectionContent = normalizedPageContent(from: page)
|
||||
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
||||
selectionContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: selectionRange
|
||||
)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = normalizedPageContent(from: page)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
textView.attributedText = nil
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
#endif
|
||||
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
highlights: highlights,
|
||||
searchState: searchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
selectionController.clearSelection(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
selectionController.handleLongPress(
|
||||
gesture,
|
||||
page: currentPage,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
if gesture.state == .ended {
|
||||
showSelectionMenuIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
selectionController.handleTap(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy)
|
||||
}
|
||||
|
||||
@objc private func rd_highlight(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight)
|
||||
}
|
||||
|
||||
@objc private func rd_annotate(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
#if canImport(DTCoreText)
|
||||
selectionController.showSelectionMenuIfNeeded(
|
||||
in: self,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController,
|
||||
copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)),
|
||||
highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)),
|
||||
annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:))
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
let image = coverImage(from: page.content) else {
|
||||
return false
|
||||
}
|
||||
|
||||
coverImageView.image = image
|
||||
coverImageView.isHidden = false
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = true
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
return true
|
||||
}
|
||||
|
||||
private func coverImage(from content: NSAttributedString) -> UIImage? {
|
||||
guard content.length > 0 else { return nil }
|
||||
var resolvedImage: UIImage?
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
||||
guard let image = image(from: value) else { return }
|
||||
resolvedImage = image
|
||||
stop.pointee = true
|
||||
}
|
||||
return resolvedImage
|
||||
}
|
||||
|
||||
private func image(from attachmentValue: Any?) -> UIImage? {
|
||||
#if canImport(DTCoreText)
|
||||
if let attachment = attachmentValue as? DTTextAttachment,
|
||||
let url = attachment.contentURL {
|
||||
return UIImage(contentsOfFile: url.path)
|
||||
}
|
||||
#endif
|
||||
if let attachment = attachmentValue as? NSTextAttachment {
|
||||
if let image = attachment.image {
|
||||
return image
|
||||
}
|
||||
if let data = attachment.contents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
let data = fileWrapper.regularFileContents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
proxy.removeAttribute(.backgroundColor, range: fullRange)
|
||||
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
|
||||
|
||||
var attachmentRanges: [NSRange] = []
|
||||
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard value != nil else { return }
|
||||
attachmentRanges.append(range)
|
||||
}
|
||||
|
||||
for range in attachmentRanges.reversed() {
|
||||
let replacement = NSAttributedString(
|
||||
string: String(repeating: " ", count: max(range.length, 1)),
|
||||
attributes: [
|
||||
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
|
||||
.foregroundColor: UIColor.clear
|
||||
]
|
||||
)
|
||||
proxy.replaceCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else {
|
||||
return content
|
||||
}
|
||||
|
||||
let text = content.string as NSString
|
||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||
guard firstParagraphRange.length > 0 else {
|
||||
return content
|
||||
}
|
||||
|
||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||||
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
||||
mutableStyle.paragraphSpacingBefore = 0
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
||||
let pageStart = page.pageStartOffset
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else {
|
||||
return false
|
||||
}
|
||||
|
||||
let chapterText = page.chapterContent.string as NSString
|
||||
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
let displayRange = coreTextDisplayRange,
|
||||
let page = currentPage,
|
||||
coreTextContentView.bounds.width > 0,
|
||||
coreTextContentView.bounds.height > 0 else {
|
||||
interactionController.configure(layoutFrame: nil, page: currentPage)
|
||||
return
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
interactionController.configure(layoutFrame: nil, page: page)
|
||||
return
|
||||
}
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||
coreTextContentView.layoutFrame = layoutFrame
|
||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||
overlayView.updateSnapshot(interactionController.snapshot)
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import UIKit
|
||||
|
||||
/// 背景覆盖层,负责绘制页内装饰和位于正文下方的提示层。
|
||||
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
|
||||
@ -0,0 +1,41 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染。
|
||||
final class RDEPUBTextPageRenderView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
contentMode = .redraw
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -0,0 +1,189 @@
|
||||
import UIKit
|
||||
|
||||
/// 负责管理文本选区、长按交互和菜单定位。
|
||||
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||||
private var isSelectionFromInteraction = false
|
||||
private var selectionAnchorPoint: CGPoint?
|
||||
private var selectionMenuAnchorRect: CGRect?
|
||||
|
||||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||||
|
||||
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool {
|
||||
overlayView.selectionRange?.length ?? 0 > 0
|
||||
}
|
||||
|
||||
func clearSelection(
|
||||
textView: UITextView,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
|
||||
) {
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView?.clearSelection()
|
||||
selectionAnchorPoint = nil
|
||||
selectionMenuAnchorRect = nil
|
||||
isSelectionFromInteraction = false
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
onSelectionChanged?(nil)
|
||||
}
|
||||
|
||||
func handleLongPress(
|
||||
_ gesture: UILongPressGestureRecognizer,
|
||||
page: RDEPUBTextPage?,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
let point = gesture.location(in: overlayView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
selectionAnchorPoint = point
|
||||
isSelectionFromInteraction = true
|
||||
handleSelectionFromInteraction(
|
||||
point: point,
|
||||
anchorPoint: nil,
|
||||
page: page,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
case .changed:
|
||||
guard let anchor = selectionAnchorPoint else { return }
|
||||
handleSelectionFromInteraction(
|
||||
point: point,
|
||||
anchorPoint: anchor,
|
||||
page: page,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
case .ended:
|
||||
isSelectionFromInteraction = false
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handleTap(
|
||||
textView: UITextView,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
|
||||
) {
|
||||
clearSelection(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
func showSelectionMenuIfNeeded(
|
||||
in hostView: UIView,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
interactionController: RDEPUBPageInteractionController,
|
||||
copyAction: Selector,
|
||||
highlightAction: Selector,
|
||||
annotateAction: Selector
|
||||
) {
|
||||
guard overlayView.selectionRange?.length ?? 0 > 0,
|
||||
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
hostView.becomeFirstResponder()
|
||||
let menuRect = overlayView.convert(anchorRect, to: hostView)
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: copyAction),
|
||||
UIMenuItem(title: "高亮", action: highlightAction),
|
||||
UIMenuItem(title: "批注", action: annotateAction)
|
||||
]
|
||||
menuController.setTargetRect(menuRect, in: hostView)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
||||
guard !isSelectionFromInteraction else { return }
|
||||
guard let page else {
|
||||
onSelectionChanged?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
guard selectedRange.location != NSNotFound,
|
||||
selectedRange.length > 0,
|
||||
let attributedText = textView.attributedText else {
|
||||
onSelectionChanged?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let source = attributedText.string as NSString
|
||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
onSelectionChanged?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
onSelectionChanged?(selection)
|
||||
}
|
||||
|
||||
private func handleSelectionFromInteraction(
|
||||
point: CGPoint,
|
||||
anchorPoint: CGPoint?,
|
||||
page: RDEPUBTextPage?,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let page else { return }
|
||||
|
||||
let range: NSRange?
|
||||
if let anchorPoint {
|
||||
range = interactionController.selectionRange(from: anchorPoint, to: point)
|
||||
} else if let idx = interactionController.characterIndex(at: point) {
|
||||
range = NSRange(location: idx, length: 1)
|
||||
} else {
|
||||
range = nil
|
||||
}
|
||||
|
||||
guard let range else { return }
|
||||
let rects = interactionController.selectionRects(for: range)
|
||||
overlayView.updateSelection(absoluteRange: range, rects: rects)
|
||||
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
|
||||
onSelectionChanged?(makeSelection(from: range, page: page))
|
||||
}
|
||||
|
||||
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||
let source = page.chapterContent.string as NSString
|
||||
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterLength = max(page.chapterContent.length - 1, 1)
|
||||
let chapterStart = max(range.location, 0)
|
||||
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
|
||||
return RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(chapterStart) / Double(chapterLength),
|
||||
lastProgression: Double(chapterEnd) / Double(chapterLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import UIKit
|
||||
|
||||
/// 翻页控制器:管理 UIPageViewController 的创建、切换、故障修复和翻页请求队列。
|
||||
///
|
||||
/// RDReaderView 持有此控制器,将 pageCurl 模式的容器管理职责下沉。
|
||||
struct RDReaderPagingController {
|
||||
/// 当前 pageCurl 翻页请求模型
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
/// 排队中的翻页请求(pageCurl 动画过程中收到新跳转时排队)
|
||||
var pendingTransitionRequest: PageTransitionRequest?
|
||||
|
||||
/// 是否正在执行页面转场动画
|
||||
var isTransitioning: Bool = false
|
||||
|
||||
/// UI 是否已经构建完成(避免重复构建)
|
||||
var didBuildUI = false
|
||||
|
||||
// MARK: - PageViewController 工厂
|
||||
|
||||
/// 创建带指定 spine 位置的 UIPageViewController
|
||||
static func createPageViewController(isDualPage: Bool) -> UIPageViewController {
|
||||
let options: [UIPageViewController.OptionsKey: Any]?
|
||||
if isDualPage {
|
||||
options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
|
||||
} else {
|
||||
options = nil
|
||||
}
|
||||
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
|
||||
pageVC.isDoubleSided = isDualPage
|
||||
return pageVC
|
||||
}
|
||||
|
||||
// MARK: - 翻页请求队列管理
|
||||
|
||||
/// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool {
|
||||
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
|
||||
pendingTransitionRequest = request
|
||||
return true
|
||||
}
|
||||
|
||||
/// 收尾 pageCurl 转场,返回排队中的后续请求(如果有的话)。
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest? {
|
||||
isTransitioning = false
|
||||
guard let pending = pendingTransitionRequest else { return nil }
|
||||
pendingTransitionRequest = nil
|
||||
return pending
|
||||
}
|
||||
|
||||
/// 重置所有排队状态(用于故障修复)。
|
||||
mutating func resetPendingState() {
|
||||
isTransitioning = false
|
||||
pendingTransitionRequest = nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,256 @@
|
||||
import UIKit
|
||||
|
||||
final class RDReaderPreloadController {
|
||||
var radius: Int = 1
|
||||
|
||||
private let preloadHostView = UIView()
|
||||
private var preloadedPageViews: [Int: UIView] = [:]
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
struct Environment {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
let landscapeDualPageEnabled: Bool
|
||||
let coverPageIndex: Int?
|
||||
let totalPages: Int
|
||||
let spreadResolver: RDReaderSpreadResolver
|
||||
}
|
||||
|
||||
private struct CacheSignature: Equatable {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
func setHostFrame(_ frame: CGRect) {
|
||||
preloadHostView.frame = frame
|
||||
}
|
||||
|
||||
func ensureHostView(in parentView: UIView) {
|
||||
guard preloadHostView.superview == nil else { return }
|
||||
preloadHostView.isHidden = true
|
||||
preloadHostView.isUserInteractionEnabled = false
|
||||
preloadHostView.clipsToBounds = true
|
||||
preloadHostView.frame = parentView.bounds
|
||||
preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
parentView.insertSubview(preloadHostView, at: 0)
|
||||
}
|
||||
|
||||
func initializeSignature(_ environment: Environment) {
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
func invalidate(environment: Environment) {
|
||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||
pageCurlCachedViews.removeAll()
|
||||
preloadedPageViews.removeAll()
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
func pageViewForDisplay(
|
||||
pageNum: Int,
|
||||
environment: Environment,
|
||||
contentViewProvider: (Int, UIView?) -> UIView?
|
||||
) -> UIView {
|
||||
let reusableView = detachedReusablePageView(for: pageNum)
|
||||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
return view
|
||||
}
|
||||
|
||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||
preloaded?.removeFromSuperview()
|
||||
return preloaded
|
||||
}
|
||||
|
||||
func prime(
|
||||
around pageNum: Int,
|
||||
preferredForward: Bool? = nil,
|
||||
parentView: UIView,
|
||||
environment: Environment,
|
||||
contentViewProvider: (Int, UIView?) -> UIView?
|
||||
) {
|
||||
refreshCacheSignatureIfNeeded(environment)
|
||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
||||
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
|
||||
trimCachedPageViews(keeping: keepSet)
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
ensureHostView(in: parentView)
|
||||
preloadHostView.frame = parentView.bounds
|
||||
|
||||
for targetPage in targets {
|
||||
let existing = detachedReusablePageView(for: targetPage)
|
||||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
if contentView.superview !== preloadHostView {
|
||||
contentView.removeFromSuperview()
|
||||
preloadHostView.addSubview(contentView)
|
||||
}
|
||||
contentView.frame = preloadHostView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
private func currentCacheSignature(_ environment: Environment) -> CacheSignature {
|
||||
CacheSignature(
|
||||
displayType: environment.displayType,
|
||||
isLandscape: environment.isLandscape,
|
||||
pagesPerScreen: environment.pagesPerScreen,
|
||||
boundsSize: environment.boundsSize
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshCacheSignatureIfNeeded(_ environment: Environment) {
|
||||
let signature = currentCacheSignature(environment)
|
||||
if cacheSignature != signature {
|
||||
invalidate(environment: environment)
|
||||
} else if cacheSignature == nil {
|
||||
cacheSignature = signature
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool {
|
||||
pageNum >= 0
|
||||
&& pageNum != RDReaderView.blankPageNum
|
||||
&& pageNum != RDReaderView.blankEndPageNum
|
||||
&& pageNum < environment.totalPages
|
||||
}
|
||||
|
||||
private func isDualPage(environment: Environment) -> Bool {
|
||||
environment.landscapeDualPageEnabled
|
||||
&& environment.isLandscape
|
||||
&& environment.displayType != .verticalScroll
|
||||
}
|
||||
|
||||
private func spreadStart(for pageNum: Int, environment: Environment) -> Int? {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return nil }
|
||||
guard isDualPage(environment: environment) else { return pageNum }
|
||||
return environment.spreadResolver.dualPagePair(
|
||||
for: pageNum,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
).left
|
||||
}
|
||||
|
||||
private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set<Int> {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return [] }
|
||||
guard isDualPage(environment: environment) else { return [pageNum] }
|
||||
let pair = environment.spreadResolver.dualPagePair(
|
||||
for: pageNum,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
)
|
||||
var pages: Set<Int> = [pair.left]
|
||||
if let right = pair.right, shouldCachePage(right, environment: environment) {
|
||||
pages.insert(right)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? {
|
||||
guard environment.totalPages > 0,
|
||||
let spreadStart = spreadStart(for: pageNum, environment: environment) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard isDualPage(environment: environment) else {
|
||||
let target = forward ? spreadStart + 1 : spreadStart - 1
|
||||
return shouldCachePage(target, environment: environment) ? target : nil
|
||||
}
|
||||
|
||||
if forward {
|
||||
return environment.spreadResolver.adjacentDualPage(
|
||||
from: spreadStart,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex,
|
||||
forward: true
|
||||
)
|
||||
}
|
||||
|
||||
let pair = environment.spreadResolver.dualPagePair(
|
||||
for: spreadStart,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
)
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return environment.spreadResolver.dualPagePair(
|
||||
for: prevEnd,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
).left
|
||||
}
|
||||
|
||||
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
|
||||
spreadPageNumbers(startingAt: anchorPage, environment: environment)
|
||||
}
|
||||
|
||||
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||
preloaded.removeFromSuperview()
|
||||
return preloaded
|
||||
}
|
||||
|
||||
guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
pageCurlCachedViews.removeValue(forKey: pageNum)
|
||||
return cached
|
||||
}
|
||||
|
||||
private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
|
||||
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
if !keep {
|
||||
value.removeFromSuperview()
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
preloadedPageViews = preloadedPageViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
if !keep {
|
||||
value.removeFromSuperview()
|
||||
}
|
||||
return keep
|
||||
}
|
||||
}
|
||||
|
||||
private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] {
|
||||
guard environment.totalPages > 0,
|
||||
shouldCachePage(pageNum, environment: environment) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var targets = Set<Int>()
|
||||
|
||||
var previousAnchor = pageNum
|
||||
for _ in 0..<max(radius, 0) {
|
||||
guard let previous = adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: previous, environment: environment))
|
||||
previousAnchor = previous
|
||||
}
|
||||
|
||||
var nextAnchor = pageNum
|
||||
for _ in 0..<max(radius, 0) {
|
||||
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: next, environment: environment))
|
||||
nextAnchor = next
|
||||
}
|
||||
|
||||
if let preferredForward,
|
||||
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment)
|
||||
: adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) {
|
||||
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor, environment: environment))
|
||||
}
|
||||
|
||||
return targets.sorted()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
struct RDReaderSpreadResolver {
|
||||
func isFullScreenPage(
|
||||
_ pageNum: Int,
|
||||
landscapeDualPageEnabled: Bool,
|
||||
isLandscape: Bool,
|
||||
coverPageIndex: Int?
|
||||
) -> Bool {
|
||||
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
|
||||
return pageNum == coverIndex
|
||||
}
|
||||
|
||||
func dualPagePair(
|
||||
for pageNum: Int,
|
||||
totalPages: Int,
|
||||
coverPageIndex: Int?
|
||||
) -> (left: Int, right: Int?) {
|
||||
if let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return (coverIndex, nil)
|
||||
}
|
||||
let adjustedIndex = pageNum - (coverIndex + 1)
|
||||
let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2
|
||||
let left = pairStart
|
||||
let right = pairStart + 1 < totalPages ? pairStart + 1 : nil
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
let left = (pageNum / 2) * 2
|
||||
let right = left + 1 < totalPages ? left + 1 : nil
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
func adjacentDualPage(
|
||||
from pageNum: Int,
|
||||
totalPages: Int,
|
||||
coverPageIndex: Int?,
|
||||
forward: Bool
|
||||
) -> Int? {
|
||||
let pair = dualPagePair(for: pageNum, totalPages: totalPages, coverPageIndex: coverPageIndex)
|
||||
if forward {
|
||||
let nextStart = (pair.right ?? pair.left) + 1
|
||||
return nextStart < totalPages ? nextStart : nil
|
||||
}
|
||||
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left
|
||||
}
|
||||
|
||||
func nextPage(
|
||||
from currentPage: Int,
|
||||
totalPages: Int,
|
||||
pagesPerScreen: Int,
|
||||
coverPageIndex: Int?,
|
||||
forward: Bool
|
||||
) -> Int? {
|
||||
if pagesPerScreen > 1 {
|
||||
return adjacentDualPage(
|
||||
from: currentPage,
|
||||
totalPages: totalPages,
|
||||
coverPageIndex: coverPageIndex,
|
||||
forward: forward
|
||||
)
|
||||
}
|
||||
|
||||
let target = currentPage + (forward ? 1 : -1)
|
||||
guard target >= 0, target < totalPages else { return nil }
|
||||
return target
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import CoreGraphics
|
||||
|
||||
struct RDReaderTapRegionHandler {
|
||||
func resolveTapEvent(
|
||||
point: CGPoint,
|
||||
viewFrame: CGRect,
|
||||
isToolViewVisible: Bool
|
||||
) -> RDReaderView.TapEvent {
|
||||
let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
|
||||
if leftFrame.contains(point) {
|
||||
return isToolViewVisible ? .center : .left
|
||||
}
|
||||
|
||||
if centerFrame.contains(point) {
|
||||
return .center
|
||||
}
|
||||
|
||||
if rightFrame.contains(point) {
|
||||
return isToolViewVisible ? .center : .right
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import UIKit
|
||||
|
||||
/// UICollectionView 数据源和自定义布局代理实现
|
||||
/// 处理水平滚动和垂直滚动两种翻页模式
|
||||
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
|
||||
/// 配置 UICollectionViewCell
|
||||
/// 通过 DataSource 获取页面内容视图,支持 cell 复用和预加载视图复用
|
||||
/// RTL 水平模式下翻转 cell 内容使文字方向正常
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
|
||||
if let identifer = pageReuseIdentifier(for: indexPath.row) {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
|
||||
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
|
||||
let reusableView = cell.containerView ?? preloadedView
|
||||
cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||
// RTL 水平模式:翻转 cell 内容使文字方向正常(collectionView 已整体翻转)
|
||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
} else {
|
||||
cell.contentView.transform = .identity
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
/// 返回每组的 item 数量,即总页数
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return numberOfPages()
|
||||
}
|
||||
|
||||
/// 布局代理回调:当前可见页码变化时通知
|
||||
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
|
||||
if currentPage >= 0, currentPage != pageIndex {
|
||||
predictedPageDirection = pageIndex >= currentPage
|
||||
}
|
||||
currentPage = pageIndex
|
||||
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
|
||||
}
|
||||
|
||||
/// 布局数据源回调:返回垂直滚动模式下指定页的高度
|
||||
/// 当前返回 nil 使用默认高度
|
||||
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import UIKit
|
||||
|
||||
/// 内容视图注册和复用扩展
|
||||
/// 提供类似 UITableView 的 register/dequeueReusable 机制
|
||||
extension RDReaderView {
|
||||
/// 注册内容视图类型
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
|
||||
contentViews[identifier] = contentView
|
||||
collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier)
|
||||
}
|
||||
|
||||
/// 获取可复用的内容视图
|
||||
/// 优先从当前显示的 cell 中获取,其次创建新实例
|
||||
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
|
||||
if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView {
|
||||
return containerView
|
||||
}
|
||||
let contentViewClass = contentViews[identifier]
|
||||
assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)")
|
||||
var contentView = contentViewClass!.init()
|
||||
return contentView
|
||||
}
|
||||
|
||||
/// 获取指定页码的内容视图
|
||||
/// pageCurl 模式从 PageViewController 获取,滚动模式从 CollectionView cell 获取
|
||||
public func pageContentView(pageNum: Int) -> UIView? {
|
||||
if currentDisplayType == .pageCurl {
|
||||
return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView
|
||||
} else {
|
||||
let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell
|
||||
return cell?.containerView
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前阅读模式下单页内容容器的实际尺寸。
|
||||
/// 优先使用已经显示出来的内容视图尺寸;若页面尚未装载,则回退到当前模式下的理论单页尺寸。
|
||||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize {
|
||||
let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil)
|
||||
if let targetPage,
|
||||
let contentView = pageContentView(pageNum: targetPage),
|
||||
contentView.bounds.width > 0,
|
||||
contentView.bounds.height > 0 {
|
||||
return contentView.bounds.size
|
||||
}
|
||||
|
||||
if currentDisplayType == .pageCurl {
|
||||
if let childViewController = pageViewController.viewControllers?.first as? RDReaderPageChildViewController,
|
||||
childViewController.view.bounds.width > 0,
|
||||
childViewController.view.bounds.height > 0 {
|
||||
return childViewController.view.bounds.size
|
||||
}
|
||||
if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 {
|
||||
return pageViewController.view.bounds.size
|
||||
}
|
||||
return bounds.size
|
||||
}
|
||||
|
||||
let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size
|
||||
guard containerBounds.width > 0, containerBounds.height > 0 else {
|
||||
return bounds.size
|
||||
}
|
||||
|
||||
switch currentDisplayType {
|
||||
case .horizontalScroll:
|
||||
return CGSize(
|
||||
width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)),
|
||||
height: containerBounds.height
|
||||
)
|
||||
case .verticalScroll:
|
||||
return containerBounds
|
||||
case .pageCurl:
|
||||
return bounds.size
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift
Normal file
138
Sources/RDReaderView/ReaderView/RDReaderView+PageCurl.swift
Normal file
@ -0,0 +1,138 @@
|
||||
import UIKit
|
||||
|
||||
/// UIPageViewController 数据源和代理实现
|
||||
/// 处理仿真翻页模式下的页面数据供给和翻页事件
|
||||
extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
|
||||
/// 创建指定页码的子控制器
|
||||
/// 空白页返回空视图,正常页从缓存或数据源获取内容
|
||||
private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController {
|
||||
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
|
||||
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
|
||||
}
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
}
|
||||
|
||||
/// 计算某页的"下一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
let totalPages = numberOfPages()
|
||||
|
||||
// 末尾空白页之后没有更多页
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return RDReaderView.blankPageNum
|
||||
}
|
||||
if pageNum == RDReaderView.blankPageNum {
|
||||
let firstContent = coverIndex + 1
|
||||
return firstContent < totalPages ? firstContent : nil
|
||||
}
|
||||
}
|
||||
|
||||
let next = pageNum + 1
|
||||
if next < totalPages {
|
||||
return next
|
||||
}
|
||||
|
||||
// pageNum 是最后一页,检查在双页模式下是否需要空白页配对
|
||||
if isDualPage {
|
||||
if let coverIndex = coverPageIndex {
|
||||
let adjustedIndex = pageNum - (coverIndex + 1)
|
||||
if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
|
||||
return RDReaderView.blankEndPageNum
|
||||
}
|
||||
} else {
|
||||
if pageNum % 2 == 0 {
|
||||
return RDReaderView.blankEndPageNum
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 计算某页的"上一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
let totalPages = numberOfPages()
|
||||
return totalPages > 0 ? totalPages - 1 : nil
|
||||
}
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == RDReaderView.blankPageNum {
|
||||
return coverIndex
|
||||
}
|
||||
if pageNum == coverIndex + 1 {
|
||||
return RDReaderView.blankPageNum
|
||||
}
|
||||
}
|
||||
let prev = pageNum - 1
|
||||
return prev >= 0 ? prev : nil
|
||||
}
|
||||
|
||||
/// UIPageViewController 数据源:返回当前页之前(左/上)的页面控制器
|
||||
/// RTL 模式下 before/after 语义互换:before(向右翻)= 下一页
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
let targetNum = isRTL
|
||||
? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
: prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willPreviousTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
/// UIPageViewController 数据源:返回当前页之后(右/下)的页面控制器
|
||||
/// RTL 模式下 before/after 语义互换
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
let targetNum = isRTL
|
||||
? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
: nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willNextTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
/// UIPageViewController 代理:翻页动画完成回调
|
||||
/// 更新当前页码,触发预加载,并检测 PageViewController 异常状态
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
|
||||
let pn = firstVC.pageNum
|
||||
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
|
||||
currentPage = pn
|
||||
primePageCache(around: pn, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
predictedPageDirection = nil
|
||||
finishPageCurlTransition()
|
||||
}
|
||||
|
||||
/// UIPageViewController 代理:即将开始转场动画
|
||||
/// 记录预测的翻页方向,用于优化预加载策略
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
pagingController.isTransitioning = true
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
|
||||
target.pageNum != RDReaderView.blankPageNum,
|
||||
target.pageNum != RDReaderView.blankEndPageNum {
|
||||
predictedPageDirection = target.pageNum >= currentPage
|
||||
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift
Normal file
115
Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift
Normal file
@ -0,0 +1,115 @@
|
||||
import UIKit
|
||||
|
||||
/// 工具栏管理扩展:处理工具栏的显示/隐藏动画、安装、布局
|
||||
extension RDReaderView {
|
||||
|
||||
/// 点击屏幕中央区域,切换工具栏的显示/隐藏
|
||||
func tapCenter() {
|
||||
refreshToolViewsFromProviderIfNeeded()
|
||||
print("[Debug] tapCenter called, isShowToolView was: \(isShowToolView), topToolView: \(String(describing: topToolView)), onBack: \(String(describing: (topToolView as? RDEPUBReaderTopToolView)?.onBack))")
|
||||
isShowToolView = !isShowToolView
|
||||
if isShowToolView {
|
||||
if let topToolView = topToolView {
|
||||
installToolViewIfNeeded(topToolView, position: .top)
|
||||
layoutIfNeeded()
|
||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||
topToolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
if let bottomToolView = bottomToolView {
|
||||
installToolViewIfNeeded(bottomToolView, position: .bottom)
|
||||
layoutIfNeeded()
|
||||
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||
bottomToolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
pageViewController.view.isUserInteractionEnabled = false
|
||||
} else {
|
||||
if let topToolView = topToolView {
|
||||
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
|
||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||
}) { _ in
|
||||
topToolView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
if let bottomToolView = bottomToolView {
|
||||
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
|
||||
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
||||
}) { _ in
|
||||
bottomToolView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
pageViewController.view.isUserInteractionEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断点击命中的视图是否在指定工具栏内
|
||||
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if toolView.frame.contains(point) {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let hitView else { return false }
|
||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||
}
|
||||
|
||||
enum ToolViewPosition {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
|
||||
guard toolView.superview !== self else { return }
|
||||
|
||||
toolView.removeFromSuperview()
|
||||
addSubview(toolView)
|
||||
toolView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let heightConstraint: NSLayoutConstraint
|
||||
switch position {
|
||||
case .top:
|
||||
topToolViewHeightConstraint?.isActive = false
|
||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top))
|
||||
topToolViewHeightConstraint = heightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
toolView.topAnchor.constraint(equalTo: topAnchor),
|
||||
heightConstraint
|
||||
])
|
||||
case .bottom:
|
||||
bottomToolViewHeightConstraint?.isActive = false
|
||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom))
|
||||
bottomToolViewHeightConstraint = heightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
toolView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
heightConstraint
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func updateToolViewHeightConstraintsIfNeeded() {
|
||||
topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
|
||||
bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
|
||||
}
|
||||
|
||||
private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat {
|
||||
let contentHeight: CGFloat = 52
|
||||
switch position {
|
||||
case .top:
|
||||
return safeAreaInsets.top + contentHeight
|
||||
case .bottom:
|
||||
return safeAreaInsets.bottom + contentHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
90
Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift
Normal file
90
Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift
Normal file
@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
|
||||
/// 阅读器数据源协议
|
||||
/// 上层控制器通过实现此协议,向 RDReaderView 提供页面数量、内容视图和工具栏。
|
||||
/// 所有方法由 RDReaderView 在需要渲染页面时回调。
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
/// 返回阅读器的总页数
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
/// 返回指定页码的内容视图
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
/// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
/// 返回顶部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
/// 返回底部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
/// 与内容格式无关的统一分页提供者协议。
|
||||
/// 逐步替代面向 EPUB 命名的 ``RDReaderDataSource``,方便后续复用到 PDF 等其他阅读场景。
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
/// 阅读器代理协议
|
||||
/// 上层控制器通过实现此协议,接收翻页和横竖屏切换事件
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
/// 翻页回调,当当前显示页码变化时触发
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
/// 横竖屏切换时回调,可在此重新分页
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
|
||||
// MARK: - 枚举
|
||||
|
||||
extension RDReaderView {
|
||||
/// 翻页模式枚举,决定 RDReaderView 使用哪种底层视图来展示内容
|
||||
public enum DisplayType {
|
||||
/// 仿真翻页:使用 UIPageViewController,模拟真实翻书效果
|
||||
case pageCurl
|
||||
/// 水平滚动:使用 UICollectionView + 自定义布局,每屏一项,水平分页
|
||||
case horizontalScroll
|
||||
/// 垂直滚动:使用 UICollectionView,全宽项目,垂直连续滚动
|
||||
case verticalScroll
|
||||
}
|
||||
|
||||
/// 翻页方向枚举
|
||||
public enum PageDirection {
|
||||
/// 从左往右翻页(默认,适用于中文/英文书籍)
|
||||
case leftToRight
|
||||
/// 从右往左翻页(适用于日文漫画等)
|
||||
case rightToLeft
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy 适配器
|
||||
|
||||
final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider {
|
||||
weak var dataSource: RDReaderDataSource?
|
||||
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||
dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0
|
||||
}
|
||||
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView()
|
||||
}
|
||||
|
||||
func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
|
||||
dataSource?.pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
dataSource?.topToolView?(readerView: readerView)
|
||||
}
|
||||
|
||||
func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
dataSource?.bottomToolView?(readerView: readerView)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user