feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -75,7 +75,7 @@ public enum RDEPUBCFIDOMPathBuilder {
|
||||
match.numberOfRanges > 2 else {
|
||||
return nil
|
||||
}
|
||||
return nsAttributes.substring(with: match.range(at: 2)).nilIfEmpty
|
||||
return nsAttributes.substring(with: match.range(at: 2)).rd_cfiNilIfEmpty
|
||||
}
|
||||
|
||||
static func isIgnorableTag(_ tagName: String) -> Bool {
|
||||
@@ -162,7 +162,7 @@ enum RDEPUBCFITextNodeMapBuilder {
|
||||
}
|
||||
|
||||
// Use comment/CDATA-stripped HTML for both path building and tag matching
|
||||
let cleanedHTML = stripCommentsAndCDATA(rawHTML)
|
||||
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
|
||||
|
||||
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
|
||||
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
|
||||
@@ -516,6 +516,14 @@ private struct RDEPUBNormalizedTextIndex {
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var rd_cfiNilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var fnv1a64: UInt64 {
|
||||
let offsetBasis: UInt64 = 14695981039346656037
|
||||
|
||||
@@ -114,7 +114,7 @@ public enum RDEPUBCFIParser {
|
||||
if let open = rawStep.firstIndex(of: "["),
|
||||
let close = rawStep.lastIndex(of: "]"),
|
||||
open < close {
|
||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).nilIfEmpty
|
||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_cfiNilIfEmpty
|
||||
} else {
|
||||
idAssertion = nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@ public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
||||
|
||||
public init(index: Int, idAssertion: String? = nil) {
|
||||
self.index = index
|
||||
self.idAssertion = idAssertion?.nilIfEmpty
|
||||
self.idAssertion = idAssertion?.rd_cfiNilIfEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,13 @@ enum RDEPUBCFIRecoveryEngine {
|
||||
)
|
||||
}
|
||||
|
||||
if let token = tokenRecoveredResult(cfi: cfi, cfiMap: cfiMap, chapterText: chapterText, lastOffset: lastOffset) {
|
||||
if let token = tokenRecoveredResult(
|
||||
cfi: cfi,
|
||||
cfiMap: cfiMap,
|
||||
chapterText: chapterText,
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
) {
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -123,6 +129,7 @@ enum RDEPUBCFIRecoveryEngine {
|
||||
cfi: RDEPUBCFI,
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
chapterText: String?,
|
||||
fallbackOffset: Int?,
|
||||
lastOffset: Int
|
||||
) -> RDEPUBCFIRecoveryResult? {
|
||||
guard let cfiMap,
|
||||
@@ -149,6 +156,7 @@ enum RDEPUBCFIRecoveryEngine {
|
||||
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
|
||||
guard !normalizedText.isEmpty else { return nil }
|
||||
|
||||
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
|
||||
var bestOffset: Int?
|
||||
var bestScore = Int.max
|
||||
|
||||
@@ -161,7 +169,14 @@ enum RDEPUBCFIRecoveryEngine {
|
||||
lastOffset: lastOffset,
|
||||
windowRadius: 768
|
||||
)
|
||||
let score = abs(calibrated - approximateOffset)
|
||||
let score = tokenRecoveryScore(
|
||||
for: anchor,
|
||||
targetPath: cfi.contentPath,
|
||||
approximateOffset: approximateOffset,
|
||||
calibratedOffset: calibrated,
|
||||
preferredOffset: preferredOffset,
|
||||
allAnchors: cfiMap.recoveryMetadata.tokenIndex
|
||||
)
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestOffset = calibrated
|
||||
@@ -317,4 +332,49 @@ enum RDEPUBCFIRecoveryEngine {
|
||||
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
|
||||
min(max(offset, 0), max(lastOffset, 0))
|
||||
}
|
||||
|
||||
private static func tokenRecoveryScore(
|
||||
for anchor: RDEPUBCFITokenAnchor,
|
||||
targetPath: RDEPUBCFIPath,
|
||||
approximateOffset: Int,
|
||||
calibratedOffset: Int,
|
||||
preferredOffset: Int?,
|
||||
allAnchors: [RDEPUBCFITokenAnchor]
|
||||
) -> Int {
|
||||
var score = abs(calibratedOffset - approximateOffset)
|
||||
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
|
||||
|
||||
if let preferredOffset {
|
||||
score += abs(approximateOffset - preferredOffset)
|
||||
let expectedOccurrence = nearestOccurrence(
|
||||
forToken: anchor.token,
|
||||
preferredOffset: preferredOffset,
|
||||
anchors: allAnchors
|
||||
)
|
||||
score += abs(anchor.occurrence - expectedOccurrence) * 256
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private static func nearestOccurrence(
|
||||
forToken token: String,
|
||||
preferredOffset: Int,
|
||||
anchors: [RDEPUBCFITokenAnchor]
|
||||
) -> Int {
|
||||
anchors
|
||||
.filter { $0.token == token }
|
||||
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
|
||||
.occurrence ?? 0
|
||||
}
|
||||
|
||||
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
|
||||
let prefix = lhs.commonPrefix(with: rhs).steps.count
|
||||
let remainingLHS = lhs.steps.dropFirst(prefix)
|
||||
let remainingRHS = rhs.steps.dropFirst(prefix)
|
||||
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
|
||||
partial + abs(pair.0.index - pair.1.index)
|
||||
}
|
||||
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public enum RDEPUBCFIResolver {
|
||||
if cfi.packagePath.steps.count >= 3 {
|
||||
manifestStep = cfi.packagePath.steps[2]
|
||||
} else {
|
||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })
|
||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })
|
||||
}
|
||||
let href = manifestStep?.idAssertion
|
||||
|
||||
@@ -40,7 +40,7 @@ public enum RDEPUBCFIResolver {
|
||||
fileIndex = nil
|
||||
}
|
||||
|
||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.nilIfEmpty != nil })?.idAssertion
|
||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_cfiNilIfEmpty != nil })?.idAssertion
|
||||
return RDEPUBCFIResolverResult(
|
||||
href: href,
|
||||
fileIndex: fileIndex,
|
||||
@@ -48,4 +48,4 @@ public enum RDEPUBCFIResolver {
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
||||
public var suffix: String?
|
||||
|
||||
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
|
||||
self.prefix = prefix?.nilIfEmpty
|
||||
self.exact = exact?.nilIfEmpty
|
||||
self.suffix = suffix?.nilIfEmpty
|
||||
self.prefix = Self.trimmedOrNil(prefix)
|
||||
self.exact = Self.trimmedOrNil(exact)
|
||||
self.suffix = Self.trimmedOrNil(suffix)
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - 自定义 NSAttributedString 属性(对齐 WXRead 的 com.weread.highlight / com.weread.underline)
|
||||
|
||||
/// 高亮自定义属性名,注入到 NSAttributedString 中供 CoreText 绘制时读取
|
||||
/// 对齐 WXRead 的 kWRHighlightAttributeName = "com.weread.highlight"
|
||||
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
|
||||
|
||||
/// 下划线自定义属性名
|
||||
/// 对齐 WXRead 的 kWRUnderlineAttributeName = "com.weread.underline"
|
||||
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
|
||||
|
||||
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(
|
||||
@@ -37,51 +31,38 @@ public struct RDEPUBSelection: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 判断是否为空选择(无文本内容)
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮样式类型
|
||||
/// - 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
|
||||
@@ -89,19 +70,16 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
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),
|
||||
@@ -114,25 +92,24 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分页截断原因,描述为什么在当前位置分页
|
||||
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(
|
||||
@@ -157,18 +134,15 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 将 CSS hex 颜色转为 UIColor(对齐 WXRead 的 UIColorForHighlightColor,35% alpha)
|
||||
public var uiColor: UIColor {
|
||||
UIColor(rdHexString: color, alpha: 0.35)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
||||
}
|
||||
|
||||
/// 编码键,用于 Codable 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
@@ -181,7 +155,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case createdAt
|
||||
}
|
||||
|
||||
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
@@ -200,7 +173,6 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
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)
|
||||
@@ -219,19 +191,18 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
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(
|
||||
@@ -250,7 +221,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
@@ -260,7 +230,6 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
@@ -358,7 +327,6 @@ public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
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(
|
||||
@@ -66,13 +63,12 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮模型,表示用户在阅读中标记的文本片段
|
||||
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) {
|
||||
@@ -82,20 +78,18 @@ public struct EPUBChapterInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面模型,描述单页的完整信息
|
||||
/// 由 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(
|
||||
@@ -115,15 +109,14 @@ public struct EPUBPage: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 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) {
|
||||
@@ -134,38 +127,24 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 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(
|
||||
@@ -204,13 +183,10 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
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
|
||||
@@ -226,4 +202,3 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
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 var cfi: String?
|
||||
|
||||
public var lastCFI: String?
|
||||
|
||||
public var rangeCFI: String?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
||||
cfi: String? = nil,
|
||||
lastCFI: String? = nil,
|
||||
rangeCFI: String? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
@@ -28,10 +37,11 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
self.cfi = cfi?.nilIfEmpty
|
||||
self.lastCFI = lastCFI?.nilIfEmpty
|
||||
self.rangeCFI = rangeCFI?.nilIfEmpty
|
||||
}
|
||||
|
||||
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
|
||||
/// 用于在字号/横竖屏变化后估算最接近的页号
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
@@ -40,24 +50,22 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
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(
|
||||
@@ -75,16 +83,14 @@ public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口模型,描述当前屏幕可见区域的完整状态
|
||||
/// 可能跨越多个 spine 资源(如分栏布局时)
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
|
||||
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
/// 当前可见的页码
|
||||
|
||||
public var visiblePageNumber: Int
|
||||
/// 当前章节索引
|
||||
|
||||
public var chapterIndex: Int?
|
||||
/// 是否为固定版式内容
|
||||
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
@@ -100,16 +106,14 @@ public struct RDEPUBViewport: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
|
||||
/// 由 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(
|
||||
@@ -125,8 +129,6 @@ public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNoteDetector {
|
||||
|
||||
private static let noteTokens = [
|
||||
"noteref",
|
||||
"footnote",
|
||||
"endnote",
|
||||
"rearnote",
|
||||
"fnref",
|
||||
"fn-",
|
||||
"fn_",
|
||||
"note",
|
||||
"annotation"
|
||||
]
|
||||
|
||||
public static func makeReferenceIfLikelyNote(
|
||||
sourceHref: String,
|
||||
sourceCFI: String?,
|
||||
targetHref: String,
|
||||
targetFragment: String?,
|
||||
targetCFI: String?,
|
||||
targetElementHTML: String?
|
||||
) -> RDEPUBNoteReference? {
|
||||
|
||||
let haystack = [
|
||||
targetHref,
|
||||
targetFragment ?? "",
|
||||
targetElementHTML ?? ""
|
||||
].joined(separator: " ").lowercased()
|
||||
|
||||
guard noteTokens.contains(where: { haystack.contains($0) }) ||
|
||||
hasEPUBType("footnote", in: targetElementHTML) ||
|
||||
hasEPUBType("endnote", in: targetElementHTML) ||
|
||||
hasEPUBType("rearnote", in: targetElementHTML) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBNoteReference(
|
||||
sourceHref: sourceHref,
|
||||
sourceCFI: sourceCFI,
|
||||
targetHref: targetHref,
|
||||
targetFragment: targetFragment,
|
||||
targetCFI: targetCFI,
|
||||
label: targetFragment,
|
||||
kind: kind(from: haystack)
|
||||
)
|
||||
}
|
||||
|
||||
public static func kind(from text: String) -> RDEPUBNoteKind {
|
||||
let lowered = text.lowercased()
|
||||
if lowered.contains("endnote") { return .endnote }
|
||||
if lowered.contains("rearnote") { return .rearnote }
|
||||
if lowered.contains("footnote") || lowered.contains("fn") { return .footnote }
|
||||
return .unknown
|
||||
}
|
||||
|
||||
private static func hasEPUBType(_ type: String, in html: String?) -> Bool {
|
||||
guard let html else { return false }
|
||||
let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"#
|
||||
return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNoteKind: String, Codable, Equatable {
|
||||
|
||||
case footnote
|
||||
|
||||
case endnote
|
||||
|
||||
case rearnote
|
||||
|
||||
case unknown
|
||||
}
|
||||
|
||||
public struct RDEPUBNoteReference: Codable, Equatable {
|
||||
|
||||
public var sourceHref: String
|
||||
|
||||
public var sourceCFI: String?
|
||||
|
||||
public var targetHref: String
|
||||
|
||||
public var targetFragment: String?
|
||||
|
||||
public var targetCFI: String?
|
||||
|
||||
public var label: String?
|
||||
|
||||
public var kind: RDEPUBNoteKind
|
||||
|
||||
public init(
|
||||
sourceHref: String,
|
||||
sourceCFI: String? = nil,
|
||||
targetHref: String,
|
||||
targetFragment: String? = nil,
|
||||
targetCFI: String? = nil,
|
||||
label: String? = nil,
|
||||
kind: RDEPUBNoteKind = .unknown
|
||||
) {
|
||||
self.sourceHref = sourceHref
|
||||
self.sourceCFI = sourceCFI
|
||||
self.targetHref = targetHref
|
||||
self.targetFragment = targetFragment
|
||||
self.targetCFI = targetCFI
|
||||
self.label = label?.nilIfEmpty
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBResolvedNote: Equatable {
|
||||
|
||||
public var reference: RDEPUBNoteReference
|
||||
|
||||
public var sourceLocation: RDEPUBLocation?
|
||||
|
||||
public var targetLocation: RDEPUBLocation
|
||||
|
||||
public var title: String?
|
||||
|
||||
public var html: String
|
||||
|
||||
public var plainText: String
|
||||
|
||||
public init(
|
||||
reference: RDEPUBNoteReference,
|
||||
sourceLocation: RDEPUBLocation? = nil,
|
||||
targetLocation: RDEPUBLocation,
|
||||
title: String? = nil,
|
||||
html: String,
|
||||
plainText: String
|
||||
) {
|
||||
self.reference = reference
|
||||
self.sourceLocation = sourceLocation
|
||||
self.targetLocation = targetLocation
|
||||
self.title = title?.nilIfEmpty
|
||||
self.html = html
|
||||
self.plainText = plainText
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBNoteResolver {
|
||||
|
||||
private let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(resourceResolver: RDEPUBResourceResolver) {
|
||||
self.resourceResolver = resourceResolver
|
||||
}
|
||||
|
||||
public func resolveInternalLink(
|
||||
sourceHref: String,
|
||||
sourceCFI: String?,
|
||||
targetLocation: RDEPUBLocation,
|
||||
sourceLocation: RDEPUBLocation? = nil,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
) -> RDEPUBResolvedNote? {
|
||||
guard let normalizedTarget = resourceResolver.normalizedLocation(
|
||||
targetLocation,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: targetLocation.bookIdentifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fragment = normalizedTarget.fragment
|
||||
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
|
||||
sourceHref: sourceHref,
|
||||
sourceCFI: sourceCFI,
|
||||
targetHref: normalizedTarget.href,
|
||||
targetFragment: fragment,
|
||||
targetCFI: normalizedTarget.cfi,
|
||||
targetElementHTML: elementHTML
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sanitizedHTML = stripBacklinks(from: elementHTML)
|
||||
let plainText = plainText(from: sanitizedHTML)
|
||||
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RDEPUBResolvedNote(
|
||||
reference: reference,
|
||||
sourceLocation: sourceLocation,
|
||||
targetLocation: normalizedTarget,
|
||||
title: title(for: reference),
|
||||
html: sanitizedHTML,
|
||||
plainText: plainText
|
||||
)
|
||||
}
|
||||
|
||||
public func extractElementHTML(href: String, fragment: String?) -> String? {
|
||||
guard let fragment,
|
||||
!fragment.isEmpty,
|
||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
||||
let html = try? String(contentsOf: fileURL, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return elementHTML(withID: fragment, in: html)
|
||||
}
|
||||
|
||||
private func elementHTML(withID id: String, in html: String) -> String? {
|
||||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
||||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||||
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
|
||||
let startRange = Range(match.range, in: html),
|
||||
let tagRange = Range(match.range(at: 1), in: html) else {
|
||||
return nil
|
||||
}
|
||||
let tagName = String(html[tagRange])
|
||||
guard !String(html[startRange]).hasSuffix("/>") else {
|
||||
return String(html[startRange])
|
||||
}
|
||||
guard let endRange = matchingEndTagRange(
|
||||
tagName: tagName,
|
||||
in: html,
|
||||
after: startRange.upperBound
|
||||
) else {
|
||||
return String(html[startRange])
|
||||
}
|
||||
return String(html[startRange.lowerBound..<endRange.upperBound])
|
||||
}
|
||||
|
||||
private func stripBacklinks(from html: String) -> String {
|
||||
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
||||
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
|
||||
}
|
||||
|
||||
private func plainText(from html: String) -> String {
|
||||
let withoutScripts = html
|
||||
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
|
||||
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
|
||||
return withoutTags
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func title(for reference: RDEPUBNoteReference) -> String {
|
||||
switch reference.kind {
|
||||
case .footnote:
|
||||
return "脚注"
|
||||
case .endnote:
|
||||
return "尾注"
|
||||
case .rearnote:
|
||||
return "后注"
|
||||
case .unknown:
|
||||
return "注释"
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingEndTagRange(
|
||||
tagName: String,
|
||||
in html: String,
|
||||
after startIndex: String.Index
|
||||
) -> Range<String.Index>? {
|
||||
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
|
||||
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
|
||||
var depth = 1
|
||||
for match in regex.matches(in: html, options: [], range: searchRange) {
|
||||
guard let range = Range(match.range, in: html) else { continue }
|
||||
let tag = String(html[range])
|
||||
if tag.hasPrefix("</") {
|
||||
depth -= 1
|
||||
} else if !tag.hasSuffix("/>") {
|
||||
depth += 1
|
||||
}
|
||||
if depth == 0 {
|
||||
return range
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,28 @@
|
||||
// RDEPUBAssetRepository.swift
|
||||
// EPUBCore 静态资源仓库
|
||||
// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板,
|
||||
// 支持模板变量替换({{token}} 占位符)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
|
||||
enum RDEPUBAsset: String {
|
||||
/// Rangy core 等价桥接脚本,负责提供 Web range/selection 基础能力
|
||||
|
||||
case rangyCoreScript = "rangy-core"
|
||||
/// Rangy serializer 等价桥接脚本,负责 DOM range 序列化/反序列化
|
||||
|
||||
case rangySerializerScript = "rangy-serializer"
|
||||
/// WXRead 对齐的动态 CSS 注入脚本
|
||||
|
||||
case cssInjectorScript = "cssInjector"
|
||||
/// WXRead 对齐的 WeReadApi JS-Native 桥接脚本
|
||||
|
||||
case weReadAPIScript = "WeReadApi"
|
||||
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
|
||||
|
||||
case bridgeScript = "epub-bridge"
|
||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||
|
||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
||||
/// WXRead 对齐的 default.css
|
||||
|
||||
case wxReadDefaultCSS = "wxread-default"
|
||||
/// WXRead 对齐的 replace.css
|
||||
|
||||
case wxReadReplaceCSS = "wxread-replace"
|
||||
/// WXRead 对齐的 dark.css
|
||||
|
||||
case wxReadDarkCSS = "wxread-dark"
|
||||
/// WXRead 对齐的 replaceForLatinLanguageBook.css
|
||||
|
||||
case wxReadLatinReplaceCSS = "wxread-replace-latin"
|
||||
|
||||
/// 对应的文件扩展名
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
|
||||
@@ -41,13 +35,8 @@ enum RDEPUBAsset: String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换
|
||||
enum RDEPUBAssetRepository {
|
||||
/// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值
|
||||
/// - Parameters:
|
||||
/// - asset: 要加载的资源类型
|
||||
/// - replacements: 模板变量字典,key 为占位符名称,value 为替换值
|
||||
/// - Returns: 替换后的文件内容字符串
|
||||
|
||||
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
|
||||
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
|
||||
var content = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
@@ -61,7 +50,6 @@ enum RDEPUBAssetRepository {
|
||||
return content
|
||||
}
|
||||
|
||||
/// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle)
|
||||
private static var resourceBundle: Bundle {
|
||||
if let bundle = resolvedBundle {
|
||||
return bundle
|
||||
@@ -69,7 +57,6 @@ enum RDEPUBAssetRepository {
|
||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
||||
}
|
||||
|
||||
/// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
@@ -82,5 +69,4 @@ enum RDEPUBAssetRepository {
|
||||
}()
|
||||
}
|
||||
|
||||
/// 用于定位当前模块 Bundle 的标记类
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
// RDEPUBFixedLayoutTemplate.swift
|
||||
// 固定版式(Fixed Layout)HTML 模板生成器
|
||||
// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中,
|
||||
// 生成适配视口尺寸和内边距的固定版式页面 HTML。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面
|
||||
enum RDEPUBFixedLayoutTemplate {
|
||||
/// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML
|
||||
/// - Parameters:
|
||||
/// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等)
|
||||
/// - publication: EPUB 出版物,用于解析资源 URL
|
||||
/// - Returns: 完整的 HTML 字符串
|
||||
|
||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
||||
// 遍历 spread 中的每个资源,生成对应的 iframe pane HTML
|
||||
|
||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中
|
||||
let pageType: String
|
||||
if request.spread.resources.count == 1 {
|
||||
pageType = "single"
|
||||
@@ -36,7 +26,6 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
"""
|
||||
}.joined()
|
||||
|
||||
// 计算视口区域(去除内边距后的可用区域)
|
||||
let background = request.backgroundColorCSS ?? "#FFFFFF"
|
||||
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
|
||||
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
|
||||
@@ -44,7 +33,7 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
||||
// 从资源仓库加载 HTML 模板并替换占位符
|
||||
|
||||
return RDEPUBAssetRepository.string(
|
||||
for: .fixedLayoutTemplate,
|
||||
replacements: [
|
||||
|
||||
@@ -1,35 +1,27 @@
|
||||
// RDEPUBJavaScriptBridge.swift
|
||||
// 原生与 WebView 之间的 JavaScript 桥接层
|
||||
// 定义所有 JS 消息类型(progression/selection/link/error 等),
|
||||
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
|
||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||
/// 阅读进度变化事件
|
||||
|
||||
case progressionChanged = "ssReaderProgressionChanged"
|
||||
/// 文本选择变化事件
|
||||
|
||||
case selectionChanged = "ssReaderSelectionChanged"
|
||||
/// 内部链接点击事件(ss-reader:// 协议)
|
||||
|
||||
case internalLink = "ssReaderInternalLink"
|
||||
/// 外部链接点击事件(http/https/mailto/tel)
|
||||
|
||||
case externalLink = "ssReaderExternalLink"
|
||||
/// JavaScript 运行时错误事件
|
||||
|
||||
case javaScriptError = "ssReaderJSError"
|
||||
/// 固定版式渲染就绪事件
|
||||
|
||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||
}
|
||||
|
||||
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
|
||||
enum RDEPUBJavaScriptBridge {
|
||||
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
|
||||
|
||||
static var messageNames: [String] {
|
||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
||||
}
|
||||
|
||||
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
|
||||
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
|
||||
static var userScript: String {
|
||||
[
|
||||
RDEPUBAssetRepository.string(for: .rangyCoreScript),
|
||||
@@ -50,10 +42,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
].joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
/// 生成重排模式下的分页渲染脚本
|
||||
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
|
||||
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
|
||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
||||
let pageStride = max(1, request.presentation.viewportSize.width)
|
||||
@@ -87,10 +75,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成固定版式模式下的主题应用脚本
|
||||
/// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 HTML 自身布局决定)
|
||||
/// - Parameter request: 固定版式渲染请求,包含背景色等信息
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
|
||||
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
|
||||
"""
|
||||
(function() {
|
||||
@@ -105,11 +89,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成搜索高亮脚本
|
||||
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
|
||||
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
|
||||
/// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等)
|
||||
/// - Returns: 搜索高亮脚本字符串
|
||||
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
@@ -121,9 +100,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成获取当前装饰数据的脚本
|
||||
/// 从 WebView 中读取高亮和搜索高亮的 DOM 装饰信息,用于同步原生标注状态
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串,返回包含 highlights 和 search 的对象
|
||||
static func resolveDecorationsScript() -> String {
|
||||
"""
|
||||
(function() {
|
||||
@@ -133,14 +109,12 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
|
||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
}
|
||||
|
||||
/// 将高亮数组转换为 JS 可用的字典数组
|
||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
||||
highlights.compactMap { highlight in
|
||||
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
|
||||
@@ -153,7 +127,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将目标位置转换为 JS 可用的字典(含 progression、fragment)
|
||||
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
|
||||
guard let location else { return nil }
|
||||
return [
|
||||
@@ -163,7 +136,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将搜索展示信息转换为 JS 可用的字典
|
||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
||||
guard let presentation else { return nil }
|
||||
return [
|
||||
@@ -178,7 +150,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
|
||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
||||
guard let object else { return fallback }
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
@@ -189,8 +160,6 @@ enum RDEPUBJavaScriptBridge {
|
||||
return string
|
||||
}
|
||||
|
||||
/// 将 Swift 字符串转换为 JavaScript 字符串字面量
|
||||
/// nil 值转为 "null",非空值序列化为 JSON 字符串后去除外层方括号
|
||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
return jsonString(from: [value], fallback: "[null]")
|
||||
|
||||
@@ -1,71 +1,45 @@
|
||||
//
|
||||
// RDEPUBModels.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层核心数据模型定义文件。
|
||||
// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构:
|
||||
// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。
|
||||
// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 版面布局类型
|
||||
/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB)
|
||||
/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB)
|
||||
public enum RDEPUBLayout: String, Codable {
|
||||
case reflowable
|
||||
case fixed
|
||||
}
|
||||
|
||||
/// 阅读配置文件类型,决定使用哪种渲染路径
|
||||
/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染
|
||||
/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染
|
||||
/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优)
|
||||
public enum RDEPUBReadingProfile: String, Codable {
|
||||
case webInteractive
|
||||
case webFixedLayout
|
||||
case textReflowable
|
||||
}
|
||||
|
||||
/// 阅读方向(文字排版方向)
|
||||
/// - ltr: 从左到右(英文、中文横排)
|
||||
/// - rtl: 从右到左(日文竖排、阿拉伯文)
|
||||
/// - auto: 根据语言自动判断
|
||||
public enum RDEPUBReadingProgression: String, Codable {
|
||||
case ltr
|
||||
case rtl
|
||||
case auto
|
||||
}
|
||||
|
||||
/// 固定版式页面在 spread(双页展开)中的位置
|
||||
/// - left: 左页
|
||||
/// - right: 右页
|
||||
/// - center: 居中(跨页或单页居中)
|
||||
public enum RDEPUBPageSpread: String, Codable {
|
||||
case left
|
||||
case right
|
||||
case center
|
||||
}
|
||||
|
||||
/// EPUB 出版物元数据,对应 OPF 文档中 <metadata> 节点的内容
|
||||
/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等
|
||||
public struct RDEPUBMetadata: Codable, Equatable {
|
||||
/// 书籍唯一标识符(对应 <dc:identifier>)
|
||||
|
||||
public var identifier: String?
|
||||
/// 书名(对应 <dc:title>)
|
||||
|
||||
public var title: String
|
||||
/// 作者(对应 <dc:creator>)
|
||||
|
||||
public var author: String?
|
||||
/// 语言代码(对应 <dc:language>,如 "zh"、"en")
|
||||
|
||||
public var language: String?
|
||||
/// EPUB 版本号(如 "2.0"、"3.0")
|
||||
|
||||
public var version: String?
|
||||
/// 版面布局类型(reflowable 或 fixed)
|
||||
|
||||
public var layout: RDEPUBLayout
|
||||
/// spread 模式设置(对应 <meta property="rendition:spread">)
|
||||
|
||||
public var spread: String?
|
||||
/// 阅读方向(ltr / rtl / auto)
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression
|
||||
|
||||
public init(
|
||||
@@ -89,22 +63,20 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 清单项,对应 OPF 文档 <manifest> 中的每个 <item>
|
||||
/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性
|
||||
public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
/// 清单项的唯一标识符(对应 <item id="...">)
|
||||
|
||||
public var id: String
|
||||
/// 资源文件路径,相对于 OPF 文件所在目录
|
||||
|
||||
public var href: String
|
||||
/// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css")
|
||||
|
||||
public var mediaType: String
|
||||
/// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面)
|
||||
|
||||
public var properties: [String]
|
||||
/// 回退项 ID(对应 <item fallback="...">)
|
||||
|
||||
public var fallback: String?
|
||||
/// 媒体叠加文件 ID(EPUB 3 有声书功能)
|
||||
|
||||
public var mediaOverlay: String?
|
||||
/// 资源标题(用于搜索结果展示等辅助用途)
|
||||
|
||||
public var title: String?
|
||||
|
||||
public init(
|
||||
@@ -125,37 +97,31 @@ public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
/// 是否为 EPUB 3 导航文档(Nav Document)
|
||||
/// 通过检查 properties 中是否包含 "nav" 标记判断
|
||||
public var isNavigationDocument: Bool {
|
||||
properties.contains("nav")
|
||||
}
|
||||
|
||||
/// 是否为 NCX 目录文件(EPUB 2 格式)
|
||||
/// 通过 MIME 类型 "application/x-dtbncx+xml" 判断
|
||||
public var isNCX: Bool {
|
||||
mediaType == "application/x-dtbncx+xml"
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB Spine 项,对应 OPF 文档 <spine> 中的每个 <itemref>
|
||||
/// Spine 定义了阅读器中章节内容的线性阅读顺序
|
||||
public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
/// 对应 manifest 中的 item id(用于关联资源)
|
||||
|
||||
public var idref: String
|
||||
/// 标准化后的资源路径(相对于 OPF 文件所在目录)
|
||||
|
||||
public var href: String
|
||||
/// MIME 类型
|
||||
|
||||
public var mediaType: String
|
||||
/// 章节标题(从目录或 manifest 推断)
|
||||
|
||||
public var title: String
|
||||
/// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度)
|
||||
|
||||
public var linear: Bool
|
||||
/// 属性列表(如 rendition:layout-* 用于单页布局覆盖)
|
||||
|
||||
public var properties: [String]
|
||||
/// 页面在 spread 中的位置偏好(左页/右页/居中)
|
||||
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
/// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置)
|
||||
|
||||
public var layout: RDEPUBLayout?
|
||||
|
||||
public init(
|
||||
@@ -179,14 +145,12 @@ public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 目录项,支持树形层级结构
|
||||
/// 来源于 NCX 文件(EPUB 2)或 Nav Document(EPUB 3)的解析结果
|
||||
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||
/// 目录项标题(显示在目录面板中)
|
||||
|
||||
public var title: String
|
||||
/// 目标资源路径(含可能的 fragment 锚点)
|
||||
|
||||
public var href: String
|
||||
/// 子目录项列表(递归结构,支持多级目录)
|
||||
|
||||
public var children: [EPUBTableOfContentsItem]
|
||||
|
||||
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
|
||||
@@ -196,23 +160,22 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 解析器错误类型,涵盖从 ZIP 解压到 OPF 解析全链路的异常情况
|
||||
public enum RDEPUBParserError: LocalizedError {
|
||||
/// ZIP 文件无法打开(文件损坏或格式不支持)
|
||||
|
||||
case archiveOpenFailed(URL)
|
||||
/// 缺少 META-INF/container.xml 文件(EPUB 格式不合规)
|
||||
|
||||
case missingContainerXML
|
||||
/// container.xml 中未找到 <rootfile> 元素
|
||||
|
||||
case missingRootFile
|
||||
/// OPF 根文件路径无效(文件不存在)
|
||||
|
||||
case invalidRootFilePath(String)
|
||||
/// XML 解析失败(格式错误或编码问题)
|
||||
|
||||
case invalidXML(URL)
|
||||
/// spine 中引用的 manifest 项不存在(idref 不匹配)
|
||||
|
||||
case missingManifestItem(idref: String)
|
||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||
|
||||
case emptySpine
|
||||
/// 归档条目路径包含路径穿越或非法字符
|
||||
|
||||
case invalidArchiveEntryPath(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
// RDEPUBNavigatorLayoutContext.swift
|
||||
// 导航器布局上下文
|
||||
// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、
|
||||
// 重排内容内边距等,供 Paginator 和渲染层使用。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 导航器布局上下文,描述阅读器容器的布局参数
|
||||
/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入
|
||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
/// 容器视图的总尺寸
|
||||
|
||||
public var containerSize: CGSize
|
||||
/// 每屏显示的页数(横屏双页模式为 2)
|
||||
|
||||
public var pagesPerScreen: Int
|
||||
/// 安全区域内边距(刘海屏、Home Indicator 等)
|
||||
|
||||
public var safeAreaInsets: UIEdgeInsets
|
||||
/// 用户界面设备类型(phone/pad)
|
||||
|
||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
||||
/// 重排模式下的内容内边距(上下左右留白)
|
||||
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public init(
|
||||
@@ -33,7 +27,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
}
|
||||
|
||||
/// 计算单页视口尺寸(双页模式下宽度除以 2)
|
||||
public var viewportSize: CGSize {
|
||||
let width: CGFloat
|
||||
if pagesPerScreen > 1 {
|
||||
@@ -44,7 +37,6 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称)
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
//
|
||||
// RDEPUBNavigatorState.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层导航状态机定义文件。
|
||||
// 定义了阅读器导航器的状态枚举,状态流转遵循:
|
||||
// initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 导航状态枚举,描述阅读器的当前操作状态
|
||||
/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
/// 初始化状态,刚打开书籍,等待初始恢复位置
|
||||
|
||||
case initializing
|
||||
/// 加载状态,正在生成首轮分页模型
|
||||
|
||||
case loading
|
||||
/// 空闲状态,显示稳定,可接受用户操作和快照应用
|
||||
|
||||
case idle
|
||||
/// 跳转状态,正在执行目录/内部链接跳转
|
||||
|
||||
case jumping
|
||||
/// 翻页状态,用户正在翻页操作中
|
||||
|
||||
case moving
|
||||
/// 重排状态,正在重新分页(字号/横竖屏变化触发)
|
||||
|
||||
case repaginating
|
||||
|
||||
/// 是否处于可应用分页快照的稳定状态
|
||||
/// 只有 idle 状态才允许消费 staged snapshot
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
|
||||
@@ -1,45 +1,38 @@
|
||||
// RDEPUBPaginator.swift
|
||||
// EPUB 离屏分页计算器
|
||||
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
|
||||
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
|
||||
// 固定版式(Fixed Layout)直接返回 1 页。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
|
||||
public final class RDEPUBPaginator: NSObject {
|
||||
/// 当前解析器引用
|
||||
|
||||
private var parser: RDEPUBParser?
|
||||
/// 承载 WebView 的父视图(弱引用)
|
||||
|
||||
private weak var hostingView: UIView?
|
||||
/// 当前展示样式(视口尺寸、内边距、字号、行高)
|
||||
|
||||
private var presentation = RDEPUBPresentationStyle(
|
||||
viewportSize: .zero,
|
||||
contentInsets: .zero,
|
||||
fontSize: 16,
|
||||
lineHeightMultiple: 1.5
|
||||
)
|
||||
/// 全量分页完成回调(返回每个 spine 项的页数数组)
|
||||
|
||||
private var completion: (([Int]) -> Void)?
|
||||
/// 单个 spine 项分页完成回调
|
||||
|
||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
||||
/// 各 spine 项的页数结果
|
||||
|
||||
private var pageCounts: [Int] = []
|
||||
/// 待测量的 spine 项索引列表
|
||||
|
||||
private var measurementIndices: [Int] = []
|
||||
/// 当前测量偏移量(在 measurementIndices 中的位置)
|
||||
|
||||
private var currentMeasurementOffset = 0
|
||||
/// 当前 spine 项的待定页数(多轮测量取最大值)
|
||||
|
||||
private var pendingMeasurementValue = 1
|
||||
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms)
|
||||
|
||||
private var measurementPass = 0
|
||||
/// 当前测量会话 ID,用于防并发和取消过期任务
|
||||
|
||||
private var activeSessionID = UUID()
|
||||
/// 调试日志作用域标识
|
||||
|
||||
private let debugScope = "PaginatorWebView"
|
||||
|
||||
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
|
||||
private lazy var webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
@@ -70,12 +63,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 计算所有 spine 项的分页数
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器
|
||||
/// - hostingView: 承载 WebView 的父视图
|
||||
/// - presentation: 展示样式(视口、内边距、字号、行高)
|
||||
/// - completion: 完成回调,返回每个 spine 项的页数数组
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
@@ -106,7 +93,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
@@ -129,13 +115,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 仅计算单个 spine 项的分页数
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器
|
||||
/// - spineIndex: 要测量的 spine 项索引
|
||||
/// - hostingView: 承载 WebView 的父视图
|
||||
/// - presentation: 展示样式
|
||||
/// - completion: 完成回调,返回该 spine 项的页数
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@@ -167,7 +146,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@@ -192,7 +170,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
viewportSize: CGSize,
|
||||
@@ -215,7 +192,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
|
||||
private func measureNextSpineItem() {
|
||||
guard let parser else {
|
||||
finishIfNeeded()
|
||||
@@ -253,12 +229,10 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
||||
}
|
||||
|
||||
/// 获取当前正在测量的 spine 项索引
|
||||
private func currentSpineIndexForMeasurement() -> Int? {
|
||||
measurementIndices[safe: currentMeasurementOffset]
|
||||
}
|
||||
|
||||
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
|
||||
private func finishIfNeeded() {
|
||||
if let singlePageCountCompletion {
|
||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
||||
@@ -273,7 +247,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
cleanupMeasurementState()
|
||||
}
|
||||
|
||||
/// 清理测量状态,释放 WebView 和所有引用
|
||||
private func cleanupMeasurementState() {
|
||||
parser = nil
|
||||
hostingView = nil
|
||||
@@ -289,16 +262,13 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
|
||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 各轮测量记录值(用于稳定性日志)
|
||||
private var measurementPassValues: [Int] = []
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
|
||||
@@ -308,7 +278,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
return
|
||||
}
|
||||
let finalValue = max(1, pendingMeasurementValue)
|
||||
// 测量稳定性日志:三轮差异过大时告警
|
||||
|
||||
if measurementPassValues.count >= 2 {
|
||||
let minVal = measurementPassValues.min() ?? 1
|
||||
let maxVal = measurementPassValues.max() ?? 1
|
||||
@@ -331,7 +301,6 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数
|
||||
private func measureCurrentDocument(sessionID: UUID) {
|
||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
||||
@@ -357,6 +326,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
|
||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
@@ -402,7 +372,6 @@ extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// RDEPUBParser+Archive.swift
|
||||
// EPUB 归档解压与 container.xml 解析
|
||||
// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/),
|
||||
// 并解析 META-INF/container.xml 获取 OPF 文件路径。
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
|
||||
/// - Parameter containerURL: container.xml 的文件路径
|
||||
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
|
||||
|
||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||||
throw RDEPUBParserError.invalidXML(containerURL)
|
||||
@@ -30,10 +24,6 @@ extension RDEPUBParser {
|
||||
return rootFilePath
|
||||
}
|
||||
|
||||
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
|
||||
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
|
||||
/// - Parameter epubURL: EPUB 文件路径
|
||||
/// - Returns: 解压目标目录 URL
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
@@ -66,17 +56,12 @@ extension RDEPUBParser {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
/// 校验归档条目路径,防止路径穿越攻击
|
||||
/// - Parameters:
|
||||
/// - entryPath: 归档中的原始条目路径
|
||||
/// - extractionRoot: 解压根目录
|
||||
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
// 拒绝绝对路径
|
||||
|
||||
if entryPath.hasPrefix("/") {
|
||||
return nil
|
||||
}
|
||||
// 拒绝包含 .. 的路径段
|
||||
|
||||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||||
if components.contains(where: { $0 == ".." }) {
|
||||
return nil
|
||||
@@ -84,15 +69,13 @@ extension RDEPUBParser {
|
||||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||||
let standardizedDest = destinationURL.standardizedFileURL.path
|
||||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||||
// 确保最终路径位于解压根目录下
|
||||
|
||||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||||
return nil
|
||||
}
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
/// 计算 EPUB 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
@@ -106,14 +89,12 @@ extension RDEPUBParser {
|
||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||||
}
|
||||
|
||||
/// 重置解析器状态(清除解压路径和出版物数据)
|
||||
func reset() {
|
||||
extractionRootURL = nil
|
||||
opfURL = nil
|
||||
resetPublicationState()
|
||||
}
|
||||
|
||||
/// 重置出版物相关状态(metadata、manifest、spine、目录)
|
||||
func resetPublicationState() {
|
||||
metadata = RDEPUBMetadata()
|
||||
manifest = [:]
|
||||
@@ -122,9 +103,8 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
/// 解析得到的 OPF 文件相对路径
|
||||
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
func parser(
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
// RDEPUBParser+Package.swift
|
||||
// OPF(Package Document)解析与 Spine 构建
|
||||
// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine),
|
||||
// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。
|
||||
// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 从 OPF 解析结果构建 spine 阅读顺序数组
|
||||
/// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性
|
||||
/// - Parameters:
|
||||
/// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences)
|
||||
/// - opfURL: OPF 文件路径
|
||||
/// - Returns: spine 项数组
|
||||
|
||||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
let publicationLayout = packageDocument.metadata.layout
|
||||
@@ -62,47 +52,52 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
|
||||
struct OPFPackageDocument {
|
||||
var metadata: RDEPUBMetadata
|
||||
var manifest: [RDEPUBManifestItem]
|
||||
var manifestByID: [String: RDEPUBManifestItem]
|
||||
var spineReferences: [OPFSpineReference]
|
||||
/// NCX 目录文件的 manifest 项(EPUB 2)
|
||||
|
||||
var ncxItem: RDEPUBManifestItem?
|
||||
/// Navigation Document 的 manifest 项(EPUB 3)
|
||||
|
||||
var navigationItem: RDEPUBManifestItem?
|
||||
}
|
||||
|
||||
/// OPF 中 spine/itemref 元素的解析结果
|
||||
struct OPFSpineReference {
|
||||
/// 对应 manifest 中的 item id
|
||||
|
||||
var idref: String
|
||||
/// 是否为线性阅读项("no" 表示辅助内容)
|
||||
|
||||
var linear: Bool
|
||||
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
|
||||
|
||||
var properties: [String]
|
||||
/// 页面展开方向(左/右/居中)
|
||||
|
||||
var pageSpread: RDEPUBPageSpread?
|
||||
}
|
||||
|
||||
/// OPF 文档的 SAX 解析代理
|
||||
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
|
||||
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
|
||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
|
||||
private var metadata = RDEPUBMetadata()
|
||||
|
||||
private var manifestItems: [RDEPUBManifestItem] = []
|
||||
|
||||
private var spineReferences: [OPFSpineReference] = []
|
||||
|
||||
private let xmlContext = XMLParserContext()
|
||||
|
||||
private var uniqueIdentifierID: String?
|
||||
|
||||
private var currentIdentifierElementID: String?
|
||||
|
||||
private var currentMetaProperty: String?
|
||||
|
||||
private var identifierByID: [String: String] = [:]
|
||||
|
||||
private var manifestTitleByID: [String: String] = [:]
|
||||
|
||||
private var currentMetaRefinesID: String?
|
||||
|
||||
private var ncxID: String?
|
||||
|
||||
/// 将解析中间结果组装为 OPFPackageDocument 结构体
|
||||
func packageDocument() -> OPFPackageDocument {
|
||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||||
metadata.identifier = fallbackIdentifier
|
||||
@@ -251,7 +246,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
xmlContext.endElement()
|
||||
}
|
||||
|
||||
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
|
||||
private var currentSection: XMLSection {
|
||||
if xmlContext.containsElement(named: "manifest") {
|
||||
return .manifest
|
||||
@@ -265,7 +259,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return .other
|
||||
}
|
||||
|
||||
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
|
||||
private func applyMetaValue(_ value: String) {
|
||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
||||
@@ -290,7 +283,6 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法)
|
||||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
||||
if let properties = attributes["properties"]?.lowercased() {
|
||||
if properties.contains("page-spread-left") {
|
||||
@@ -320,13 +312,11 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 rendition:layout 原始值转换为布局枚举
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 文档的区域枚举,用于区分当前解析位置
|
||||
enum XMLSection {
|
||||
case metadata
|
||||
case manifest
|
||||
@@ -334,14 +324,12 @@ enum XMLSection {
|
||||
case other
|
||||
}
|
||||
|
||||
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
|
||||
enum XMLName {
|
||||
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
|
||||
|
||||
static func localName(from rawName: String) -> String {
|
||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||||
}
|
||||
|
||||
/// 将空格分隔的属性值拆分为 token 数组
|
||||
static func tokenize(_ rawValue: String?) -> [String] {
|
||||
guard let rawValue else { return [] }
|
||||
return rawValue
|
||||
@@ -349,7 +337,6 @@ enum XMLName {
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
|
||||
static func refinedID(from rawValue: String) -> String? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("#") else {
|
||||
@@ -359,14 +346,12 @@ enum XMLName {
|
||||
}
|
||||
}
|
||||
|
||||
/// XML 解析上下文,维护元素栈和当前文本内容
|
||||
final class XMLParserContext {
|
||||
/// 元素名称栈,用于追踪嵌套层级
|
||||
|
||||
private var elementStack: [String] = []
|
||||
/// 当前元素累积的文本内容
|
||||
|
||||
private var currentCharacters = ""
|
||||
|
||||
/// 去除空白后的当前文本
|
||||
var trimmedCharacters: String {
|
||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// RDEPUBParser+ReadingProfile.swift
|
||||
// EPUB 阅读配置文件判断
|
||||
// 根据 metadata 的 layout 属性和 manifest/spine 内容,
|
||||
// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 判断出版物的阅读配置文件
|
||||
/// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排
|
||||
|
||||
public func readingProfile() -> RDEPUBReadingProfile {
|
||||
if metadata.layout == .fixed {
|
||||
return .webFixedLayout
|
||||
@@ -15,9 +10,6 @@ extension RDEPUBParser {
|
||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
||||
}
|
||||
|
||||
/// 检测出版物是否包含交互内容
|
||||
/// 通过 manifest 中的脚本媒体类型和 "scripted" 属性,
|
||||
/// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签
|
||||
public func hasInteractiveContent() -> Bool {
|
||||
let interactiveMediaTypes = [
|
||||
"application/javascript",
|
||||
@@ -42,8 +34,6 @@ extension RDEPUBParser {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 检测 HTML 是否包含需要走 Web 交互渲染的特征。
|
||||
/// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `<svg>` 就误判为 interactive。
|
||||
private func containsInteractiveMarkup(_ html: String) -> Bool {
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
// RDEPUBParser+Resources.swift
|
||||
// EPUB 资源查询与 URL 解析
|
||||
// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转,
|
||||
// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 按 ID 查询 manifest 项
|
||||
|
||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
||||
manifest[id]
|
||||
}
|
||||
|
||||
/// 按 href 查询 manifest 项(忽略 fragment)
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let target = href.components(separatedBy: "#").first ?? href
|
||||
return manifest.values.first { item in
|
||||
@@ -20,13 +15,11 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else { return nil }
|
||||
return spine[index].href
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击)
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
guard let opfDirectoryURL else { return nil }
|
||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
||||
@@ -41,7 +34,6 @@ extension RDEPUBParser {
|
||||
return resolvedURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源)
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !normalizedPath.isEmpty else { return nil }
|
||||
@@ -55,7 +47,6 @@ extension RDEPUBParser {
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
||||
@@ -65,7 +56,6 @@ extension RDEPUBParser {
|
||||
return fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项)
|
||||
public func coverImage() -> UIImage? {
|
||||
let coverCandidates = manifest.values.filter { item in
|
||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
||||
@@ -79,7 +69,6 @@ extension RDEPUBParser {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 通过 spine 索引读取 HTML 内容
|
||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else {
|
||||
return nil
|
||||
@@ -87,7 +76,6 @@ extension RDEPUBParser {
|
||||
return htmlString(forRelativePath: spine[index].href)
|
||||
}
|
||||
|
||||
/// 通过相对路径读取 HTML 内容
|
||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
@@ -95,7 +83,6 @@ extension RDEPUBParser {
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
/// 将 href 相对于目录 URL 解析为标准化的相对路径
|
||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
||||
return href
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// RDEPUBParser+TOC.swift
|
||||
// EPUB 目录解析
|
||||
// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3),
|
||||
// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。
|
||||
// 内部使用 TOCTreeBuilder 构建树状目录结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 从 OPF 解析结果中解析目录
|
||||
/// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表
|
||||
|
||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
|
||||
@@ -31,7 +25,6 @@ extension RDEPUBParser {
|
||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
||||
}
|
||||
|
||||
/// 解析 NCX(EPUB 2)目录文件
|
||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
||||
@@ -49,7 +42,6 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 解析 Navigation Document(EPUB 3)目录文件
|
||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
||||
let parser = XMLParser(contentsOf: navURL) else {
|
||||
@@ -67,7 +59,6 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析)
|
||||
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
|
||||
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = components.first.map(String.init) ?? href
|
||||
@@ -91,13 +82,11 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem
|
||||
private final class TOCNode {
|
||||
var title = ""
|
||||
var href: String?
|
||||
var children: [TOCNode] = []
|
||||
|
||||
/// 将节点及其子节点递归转换为目录项
|
||||
func asItem() -> EPUBTableOfContentsItem? {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let href, !href.isEmpty else {
|
||||
@@ -111,14 +100,12 @@ private final class TOCNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构
|
||||
private final class TOCTreeBuilder {
|
||||
/// 当前嵌套路径上的节点栈
|
||||
|
||||
private var nodeStack: [TOCNode] = []
|
||||
/// 顶层根节点列表
|
||||
|
||||
private var rootNodes: [TOCNode] = []
|
||||
|
||||
/// 获取构建完成的目录项列表
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
rootNodes.compactMap { $0.asItem() }
|
||||
}
|
||||
@@ -151,7 +138,6 @@ private final class TOCTreeBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NCX 文档的 SAX 解析代理,解析 <navPoint>、<content>、<text> 元素
|
||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
@@ -216,7 +202,6 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的 <nav> 元素
|
||||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
@@ -303,7 +288,6 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 <nav> 元素是否为 TOC 导航(检查 epub:type、type、role 属性)
|
||||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||||
return true
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
// RDEPUBParser.swift
|
||||
// EPUB 解析器入口
|
||||
// 解析链路:epubURL → ZIP 解压 → container.xml → OPF → spine/TOC
|
||||
// 是 EPUBCore Publication 层的核心,解析完成后通过 makePublication() 生成 RDEPUBPublication。
|
||||
// 具体解析逻辑分散在 +Archive、+Package、+TOC、+Resources、+ReadingProfile 扩展中。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 解析器,执行从 epub 文件到出版物数据的完整解析流程
|
||||
/// 主链路:epubURL → extractArchiveIfNeeded → container.xml → OPF → metadata/manifest/spine/TOC
|
||||
public final class RDEPUBParser {
|
||||
/// 出版物元数据(标题、作者、语言、布局模式等)
|
||||
|
||||
public internal(set) var metadata = RDEPUBMetadata()
|
||||
/// 资源清单字典(key 为 manifest item ID)
|
||||
|
||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
||||
/// 阅读顺序列表(按 spine 排列的资源项)
|
||||
|
||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
||||
/// 目录树(从 NCX 或 Nav Document 解析得到)
|
||||
|
||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
||||
/// EPUB 解压后的根目录路径
|
||||
|
||||
public internal(set) var extractionRootURL: URL?
|
||||
/// OPF 文件的完整路径
|
||||
|
||||
public internal(set) var opfURL: URL?
|
||||
|
||||
/// OPF 文件所在目录的 URL(用于解析相对路径)
|
||||
public var opfDirectoryURL: URL? {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 基于当前解析结果创建 RDEPUBPublication(门面对象)
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
|
||||
/// 完整解析 EPUB 文件:解压 → container.xml → OPF
|
||||
/// - Parameter epubURL: EPUB 文件的本地路径
|
||||
public func parse(epubURL: URL) throws {
|
||||
reset()
|
||||
|
||||
@@ -56,8 +45,6 @@ public final class RDEPUBParser {
|
||||
try parseOPF(at: packageURL)
|
||||
}
|
||||
|
||||
/// 解析 OPF 文件:metadata/manifest/spine/TOC
|
||||
/// - Parameter opfURL: OPF 文件路径
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
resetPublicationState()
|
||||
|
||||
@@ -81,12 +68,10 @@ public final class RDEPUBParser {
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
|
||||
/// 获取已解析的目录
|
||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
||||
tableOfContents
|
||||
}
|
||||
|
||||
/// 解析指定的 Navigation Document 文件
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
// RDEPUBPreferences.swift
|
||||
// EPUB 阅读偏好设置
|
||||
// 封装字号、行高、内容内边距、主题颜色、固定版式适配模式等展示参数,
|
||||
// 并提供从偏好设置生成渲染请求和展示样式的便捷方法。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读偏好设置,包含所有影响渲染效果的展示参数
|
||||
public struct RDEPUBPreferences: Equatable {
|
||||
/// 字体大小(pt)
|
||||
|
||||
public var fontSize: CGFloat
|
||||
/// 行高倍数(如 1.5 表示 1.5 倍行高)
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 重排模式下的内容内边距
|
||||
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定版式下的内容内边距
|
||||
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值)
|
||||
|
||||
public var themeTextColor: String?
|
||||
/// 固定版式背景色
|
||||
|
||||
public var fixedBackgroundColor: String?
|
||||
/// 固定版式适配模式(page/width/height/contain/cover)
|
||||
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定版式双页展开模式(never/always/automatic)
|
||||
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 多栏排版栏数
|
||||
|
||||
public var numberOfColumns: Int
|
||||
/// 多栏排版栏间距
|
||||
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public init(
|
||||
@@ -56,7 +51,6 @@ public struct RDEPUBPreferences: Equatable {
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
}
|
||||
|
||||
/// 从偏好设置生成重排模式的展示样式
|
||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
||||
RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
@@ -70,7 +64,6 @@ public struct RDEPUBPreferences: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面和偏好设置生成对应的渲染请求(自动区分固定版式和重排)
|
||||
public func renderRequest(
|
||||
for page: EPUBPage,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@@ -1,67 +1,49 @@
|
||||
// RDEPUBPublication.swift
|
||||
// EPUB 出版物门面(Facade)
|
||||
// 对 RDEPUBParser 的高层封装,提供 metadata、spine、TOC 等只读访问,
|
||||
// 以及固定版式双页展开判断和 spread 生成等业务逻辑。
|
||||
// 是 EPUBCore Publication 层对外暴露的主要接口。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 出版物门面对象,封装解析结果并提供高层访问接口
|
||||
public final class RDEPUBPublication {
|
||||
/// 底层解析器(持有解析数据)
|
||||
|
||||
public let parser: RDEPUBParser
|
||||
/// 资源解析器(URL 转换和路径规范化)
|
||||
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
/// 基于解析器创建出版物门面对象
|
||||
/// - Parameter parser: 已完成解析的 EPUB 解析器
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
||||
}
|
||||
|
||||
/// 元数据代理
|
||||
public var metadata: RDEPUBMetadata {
|
||||
parser.metadata
|
||||
}
|
||||
|
||||
/// 资源清单代理
|
||||
public var manifest: [String: RDEPUBManifestItem] {
|
||||
parser.manifest
|
||||
}
|
||||
|
||||
/// 阅读顺序代理
|
||||
public var spine: [RDEPUBSpineItem] {
|
||||
parser.spine
|
||||
}
|
||||
|
||||
/// 目录代理
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
parser.tableOfContents
|
||||
}
|
||||
|
||||
/// 布局模式(fixed/reflowable)
|
||||
public var layout: RDEPUBLayout {
|
||||
metadata.layout
|
||||
}
|
||||
|
||||
/// 阅读配置文件(webFixedLayout/webInteractive/textReflowable)
|
||||
public var readingProfile: RDEPUBReadingProfile {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
/// 阅读方向(ltr/rtl/auto)
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
/// 书籍唯一标识符
|
||||
public var bookIdentifier: String? {
|
||||
metadata.identifier
|
||||
}
|
||||
|
||||
/// 判断固定版式是否启用双页展开模式
|
||||
/// 综合考虑 metadata.spread、偏好设置和视口方向
|
||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
||||
guard layout == .fixed else {
|
||||
return false
|
||||
@@ -80,7 +62,6 @@ public final class RDEPUBPublication {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据偏好和视口生成固定版式的 spread 数组
|
||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
||||
EPUBFixedSpread.makeSpreads(
|
||||
spine: spine,
|
||||
|
||||
@@ -1,58 +1,46 @@
|
||||
// RDEPUBReadingSession.swift
|
||||
// EPUB 阅读会话(状态机)
|
||||
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
|
||||
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
|
||||
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
|
||||
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public final class RDEPUBReadingSession {
|
||||
/// 分页快照类型别名(页面数组 + 章节信息数组)
|
||||
|
||||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
/// 关联的出版物
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
/// 当前导航状态
|
||||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||||
/// 当前生效的页面列表
|
||||
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
/// 当前生效的章节信息列表
|
||||
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
/// 暂存的待应用页面列表(分页计算完成后暂存,等状态允许时才生效)
|
||||
|
||||
public private(set) var stagedPages: [EPUBPage]?
|
||||
/// 暂存的待应用章节信息列表
|
||||
|
||||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||
/// 暂存快照对应的恢复位置
|
||||
|
||||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标位置(用于 fragment 精确定位)
|
||||
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||||
|
||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||
/// 当前视口信息
|
||||
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
|
||||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||||
|
||||
public init(publication: RDEPUBPublication) {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 资源解析器快捷访问
|
||||
public var resourceResolver: RDEPUBResourceResolver {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
/// 状态机转换
|
||||
public func transition(to state: RDEPUBNavigatorState) {
|
||||
navigatorState = state
|
||||
}
|
||||
|
||||
/// 重置所有运行时状态(回到 initializing)
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
@@ -63,20 +51,17 @@ public final class RDEPUBReadingSession {
|
||||
currentReadingContext = nil
|
||||
}
|
||||
|
||||
/// 设置当前生效的分页快照
|
||||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
||||
activePages = snapshot.pages
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
/// 暂存分页快照(待状态允许时才生效)
|
||||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||||
stagedPages = snapshot.pages
|
||||
stagedChapters = snapshot.chapters
|
||||
stagedRestoreLocation = restoreLocation
|
||||
}
|
||||
|
||||
/// 获取暂存的分页快照
|
||||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||||
guard let stagedPages, let stagedChapters else {
|
||||
return nil
|
||||
@@ -84,7 +69,6 @@ public final class RDEPUBReadingSession {
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
@@ -96,21 +80,18 @@ public final class RDEPUBReadingSession {
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
/// 清除暂存快照
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
/// 清除挂起的导航请求
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
pendingNavigationHighlightRangeInfo = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationLocation else {
|
||||
@@ -126,7 +107,6 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
/// 查询指定页是否存在待消费的高亮 Range 信息。
|
||||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationHighlightRangeInfo,
|
||||
@@ -146,7 +126,6 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
|
||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||||
@@ -154,7 +133,6 @@ public final class RDEPUBReadingSession {
|
||||
return page.spineIndex == spineIndex
|
||||
}
|
||||
|
||||
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
|
||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return RDEPUBLocation(
|
||||
@@ -184,7 +162,6 @@ public final class RDEPUBReadingSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的位置
|
||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard currentPageNumber > 0,
|
||||
activePages.indices.contains(currentPageNumber - 1) else {
|
||||
@@ -193,7 +170,6 @@ public final class RDEPUBReadingSession {
|
||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据位置获取初始 spine 索引
|
||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||||
guard let location,
|
||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||||
@@ -203,7 +179,6 @@ public final class RDEPUBReadingSession {
|
||||
return spineIndex
|
||||
}
|
||||
|
||||
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
|
||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||||
return nil
|
||||
@@ -244,7 +219,6 @@ public final class RDEPUBReadingSession {
|
||||
return candidates[localIndex].offset
|
||||
}
|
||||
|
||||
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@@ -271,7 +245,6 @@ public final class RDEPUBReadingSession {
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
|
||||
public func updateReadingContext(
|
||||
pageNumber: Int,
|
||||
location: RDEPUBLocation,
|
||||
@@ -320,13 +293,10 @@ public final class RDEPUBReadingSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据分页计数和偏好设置生成分页快照
|
||||
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
|
||||
public func makePaginationSnapshot(
|
||||
pageCounts: [Int],
|
||||
preferences: RDEPUBPreferences,
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
//
|
||||
// RDEPUBRenderRequest.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层渲染请求模型定义文件。
|
||||
// 包含渲染所需的展示样式(PresentationStyle)、可重排/固定版式渲染请求(RenderRequest)、
|
||||
// 固定版式适配模式(Fit)和 Spread 模式(SpreadMode)。
|
||||
// 由 RDEPUBPreferences 构建,传递给 RDEPUBWebView 执行渲染。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 固定版式页面适配模式
|
||||
/// - auto: 自动选择最佳适配策略
|
||||
/// - page: 适配整页(缩放至屏幕大小)
|
||||
/// - width: 适配宽度(按宽度等比缩放)
|
||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
||||
case auto
|
||||
case page
|
||||
case width
|
||||
}
|
||||
|
||||
/// 固定版式 Spread(双页)显示模式
|
||||
/// - automatic: 根据设备方向和屏幕尺寸自动判断
|
||||
/// - always: 始终启用双页显示
|
||||
/// - never: 始终单页显示
|
||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
||||
case automatic
|
||||
case always
|
||||
case never
|
||||
}
|
||||
|
||||
/// 渲染展示样式,WebView 和 Paginator 共用的视觉参数
|
||||
/// 由 RDEPUBPreferences 根据用户设置构建
|
||||
public struct RDEPUBPresentationStyle: Equatable {
|
||||
/// 视口尺寸(容器的可见区域大小)
|
||||
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距(上下左右的安全区域偏移)
|
||||
|
||||
public var contentInsets: UIEdgeInsets
|
||||
/// 字号(pt)
|
||||
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数(1.0 为默认行距)
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 栏数,对标 WXRead 的多栏分页配置
|
||||
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,对标 WXRead 的 columnGap
|
||||
|
||||
public var columnGap: CGFloat
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值,如 "#000000")
|
||||
|
||||
public var themeTextColor: String?
|
||||
|
||||
public init(
|
||||
@@ -71,25 +52,24 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可重排内容渲染请求,包含加载和渲染一个 reflowable spine 项所需的全部参数
|
||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
/// spine 索引
|
||||
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
|
||||
public var href: String
|
||||
/// 目标页在章节中的索引
|
||||
|
||||
public var pageIndex: Int
|
||||
/// 该章节的总页数
|
||||
|
||||
public var totalPagesInChapter: Int
|
||||
/// 展示样式
|
||||
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
|
||||
|
||||
public var targetHighlightRangeInfo: String?
|
||||
/// 高亮列表
|
||||
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@@ -115,19 +95,18 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式渲染请求,包含加载和渲染一个 fixed spread 所需的全部参数
|
||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
/// 要渲染的 spread(包含 1-2 个资源)
|
||||
|
||||
public var spread: EPUBFixedSpread
|
||||
/// 视口尺寸
|
||||
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距
|
||||
|
||||
public var contentInset: UIEdgeInsets
|
||||
/// 背景色 CSS 值
|
||||
|
||||
public var backgroundColorCSS: String?
|
||||
/// 页面适配模式(auto/page/width)
|
||||
|
||||
public var fit: RDEPUBFixedLayoutFit
|
||||
/// 搜索结果展示信息
|
||||
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@@ -147,15 +126,12 @@ public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染请求枚举,统一可重排和固定版式两种渲染模式
|
||||
/// RDEPUBWebView 根据此请求类型选择对应的加载和渲染路径
|
||||
public enum RDEPUBRenderRequest: Equatable {
|
||||
/// 可重排内容渲染请求
|
||||
|
||||
case reflowable(RDEPUBReflowableRenderRequest)
|
||||
/// 固定版式渲染请求
|
||||
|
||||
case fixed(RDEPUBFixedRenderRequest)
|
||||
|
||||
/// 是否为固定版式渲染
|
||||
public var isFixedLayout: Bool {
|
||||
if case .fixed = self {
|
||||
return true
|
||||
@@ -163,7 +139,6 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 主资源的 spine 索引
|
||||
public var primarySpineIndex: Int {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
@@ -173,7 +148,6 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主资源的路径
|
||||
public var primaryHref: String {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
// RDEPUBResourceResolver.swift
|
||||
// EPUB 资源解析器
|
||||
// 封装 RDEPUBParser 的资源查询能力,提供 href 规范化、
|
||||
// spine 索引查找、位置规范化等高层接口。是 Services 层的核心组件,
|
||||
// 被 RDEPUBPublication 和 RDEPUBReadingSession 广泛使用。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 资源解析器,负责 URL 转换、href 规范化和位置解析
|
||||
public final class RDEPUBResourceResolver {
|
||||
/// 底层解析器引用
|
||||
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
}
|
||||
|
||||
/// OPF 文件所在目录的 URL
|
||||
public var opfDirectoryURL: URL? {
|
||||
parser.opfDirectoryURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议 URL
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.resourceURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
/// 规范化 href:基于 OPF 目录和可选的 spine 索引,将相对路径解析为标准化的 OPF 内路径
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@@ -70,7 +59,6 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 规范化 href:基于另一个 href 作为基准路径
|
||||
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@@ -93,7 +81,6 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 将相对于另一个 href 的引用转换为本地文件 URL
|
||||
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
|
||||
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
|
||||
return nil
|
||||
@@ -101,7 +88,6 @@ public final class RDEPUBResourceResolver {
|
||||
return fileURL(forRelativePath: normalizedHref)
|
||||
}
|
||||
|
||||
/// 规范化位置对象:解析 href、保留 progression 和 fragment
|
||||
public func normalizedLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@@ -131,18 +117,19 @@ public final class RDEPUBResourceResolver {
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
/// 通过规范化 href 查找对应的 spine 索引
|
||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
||||
parser.spine.firstIndex { item in
|
||||
self.normalizedHref(item.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过位置查找对应的 spine 索引
|
||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let normalizedHref = normalizedHref(location.href) else {
|
||||
return nil
|
||||
@@ -150,7 +137,6 @@ public final class RDEPUBResourceResolver {
|
||||
return spineIndex(forNormalizedHref: normalizedHref)
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@@ -158,7 +144,6 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].href
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取标题
|
||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@@ -166,7 +151,6 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].title
|
||||
}
|
||||
|
||||
/// 通过 href 查询 manifest 项
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
||||
return parser.manifest.values.first {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// RDEPUBResourceURLSchemeHandler.swift
|
||||
// ss-reader:// 自定义 URL 协议处理器
|
||||
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
|
||||
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
struct DebugMetrics {
|
||||
let streamedResponses: Int
|
||||
@@ -14,18 +9,16 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
let failures: Int
|
||||
}
|
||||
|
||||
/// 自定义协议名
|
||||
public static let scheme = "ss-reader"
|
||||
/// 自定义主机名
|
||||
|
||||
public static let host = "book"
|
||||
|
||||
/// 底层解析器引用(弱引用,避免循环持有)
|
||||
private weak var parser: RDEPUBParser?
|
||||
/// 文件管理器实例
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
/// 串行队列,用于同步访问活跃任务字典
|
||||
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
||||
private static var streamedResponseCount = 0
|
||||
@@ -55,8 +48,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
||||
syncQueue.sync {
|
||||
@@ -100,20 +91,17 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消 URL Scheme 任务
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
||||
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
||||
}
|
||||
|
||||
/// 从活跃任务集合中移除已完成的任务
|
||||
private func clearTask(_ id: ObjectIdentifier) {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查任务是否仍然活跃(防止取消后继续发送数据)
|
||||
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] == true
|
||||
@@ -203,7 +191,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm":
|
||||
@@ -243,7 +230,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
|
||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
||||
@@ -253,7 +239,6 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为可选资源(字体/图片/CSS/JS 等缺失时不报错,返回空响应)
|
||||
private static func isOptionalResource(_ url: URL) -> Bool {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
// RDEPUBSearchEngine.swift
|
||||
// EPUB 全文搜索引擎
|
||||
// 遍历 spine 中的线性 HTML/XHTML 资源,提取纯文本后执行大小写不敏感的关键词搜索,
|
||||
// 返回匹配项列表(含 href、progression、previewText 等)。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 搜索引擎协议,定义全文搜索接口
|
||||
protocol RDEPUBSearchEngine {
|
||||
/// 执行全文搜索
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
/// - Returns: 匹配结果列表
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
/// HTML 全文搜索引擎实现
|
||||
/// 通过解析 HTML 为纯文本,逐章执行关键词匹配
|
||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
/// EPUB 解析器(用于读取 HTML 内容)
|
||||
|
||||
private let parser: RDEPUBParser
|
||||
/// EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
/// 创建 HTML 全文搜索引擎
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器(用于读取 HTML 内容)
|
||||
/// - publication: EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
self.parser = parser
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 执行全文搜索:遍历所有线性 spine 项,提取纯文本后匹配关键词
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@@ -53,7 +39,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return searchMatches
|
||||
}
|
||||
|
||||
/// 将 HTML 转换为纯文本(优先使用 NSAttributedString,失败时用正则兜底)
|
||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
@@ -73,7 +58,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
/// 兜底方案:用正则去除 HTML 标签并解码 HTML 实体
|
||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
return stripped
|
||||
@@ -85,7 +69,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
|
||||
/// 在纯文本中查找所有关键词匹配项(大小写不敏感)
|
||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let source = text as NSString
|
||||
let fullLength = source.length
|
||||
@@ -127,7 +110,6 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 截取匹配位置前后的文本作为预览(前后各 12 个字符)
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
// RDEPUBSearchModels.swift
|
||||
// EPUB 搜索相关数据模型
|
||||
// 定义搜索匹配项(RDEPUBSearchMatch)、搜索结果(RDEPUBSearchResult)、
|
||||
// 搜索状态(RDEPUBSearchState)和搜索展示(RDEPUBSearchPresentation)等数据结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 搜索匹配项,包含匹配位置、进度、预览文本和范围信息
|
||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
/// 匹配所在资源的规范化 href
|
||||
|
||||
public var href: String
|
||||
/// 匹配位置在文本中的进度比例(0.0 ~ 1.0)
|
||||
|
||||
public var progression: Double
|
||||
/// 匹配位置前后的预览文本
|
||||
|
||||
public var previewText: String
|
||||
/// 在当前资源内的本地匹配索引
|
||||
|
||||
public var localMatchIndex: Int
|
||||
/// 匹配范围的起始位置(字符偏移量)
|
||||
|
||||
public var rangeLocation: Int?
|
||||
/// 匹配范围的长度
|
||||
|
||||
public var rangeLength: Int
|
||||
/// 文本范围锚点(用于精确定位)
|
||||
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public var cfi: String?
|
||||
|
||||
public var rangeCFI: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
progression: Double,
|
||||
@@ -29,7 +28,9 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
localMatchIndex: Int,
|
||||
rangeLocation: Int? = nil,
|
||||
rangeLength: Int,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
||||
cfi: String? = nil,
|
||||
rangeCFI: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.progression = progression
|
||||
@@ -38,18 +39,19 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
self.rangeLocation = rangeLocation
|
||||
self.rangeLength = rangeLength
|
||||
self.rangeAnchor = rangeAnchor
|
||||
self.cfi = cfi
|
||||
self.rangeCFI = rangeCFI
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索结果摘要,包含关键词、总匹配数和当前匹配项
|
||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
|
||||
public var keyword: String
|
||||
/// 总匹配数量
|
||||
|
||||
public var totalMatchCount: Int
|
||||
/// 当前匹配项的索引(从 1 开始)
|
||||
|
||||
public var currentMatchIndex: Int?
|
||||
/// 当前高亮的匹配项
|
||||
|
||||
public var currentMatch: RDEPUBSearchMatch?
|
||||
|
||||
public init(
|
||||
@@ -65,13 +67,12 @@ public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索状态,管理关键词、匹配列表和当前索引
|
||||
public struct RDEPUBSearchState: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
|
||||
public var keyword: String
|
||||
/// 所有匹配项列表
|
||||
|
||||
public var matches: [RDEPUBSearchMatch]
|
||||
/// 当前匹配项索引(从 0 开始)
|
||||
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
||||
@@ -80,7 +81,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
}
|
||||
|
||||
/// 获取当前匹配项
|
||||
public var currentMatch: RDEPUBSearchMatch? {
|
||||
guard let currentMatchIndex,
|
||||
matches.indices.contains(currentMatchIndex) else {
|
||||
@@ -89,7 +89,6 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
return matches[currentMatchIndex]
|
||||
}
|
||||
|
||||
/// 转换为搜索结果摘要
|
||||
public var result: RDEPUBSearchResult {
|
||||
RDEPUBSearchResult(
|
||||
keyword: keyword,
|
||||
@@ -100,13 +99,12 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索展示的单个资源信息
|
||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
/// 资源的规范化 href
|
||||
|
||||
public var href: String
|
||||
/// 该资源内的匹配数量
|
||||
|
||||
public var matchCount: Int
|
||||
/// 当前活跃的本地匹配索引
|
||||
|
||||
public var activeLocalMatchIndex: Int?
|
||||
|
||||
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
|
||||
@@ -116,11 +114,10 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索展示信息,传递给 JS 桥接脚本用于渲染搜索高亮
|
||||
public struct RDEPUBSearchPresentation: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
|
||||
public var keyword: String
|
||||
/// 各资源的搜索展示信息列表
|
||||
|
||||
public var resources: [RDEPUBSearchPresentationResource]
|
||||
|
||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// RDEPUBStyleSheetBuilder.swift
|
||||
// EPUB CSS 样式构建器
|
||||
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
|
||||
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
|
||||
public enum RDEPUBStyleSheetBuilder {
|
||||
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
|
||||
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
presentation: RDEPUBPresentationStyle
|
||||
@@ -22,8 +17,6 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
return styleTag + html
|
||||
}
|
||||
|
||||
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
|
||||
/// 包含多列布局、字号、行高、主题色、高亮样式等
|
||||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
@@ -106,7 +99,6 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性)
|
||||
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
@@ -153,7 +145,6 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数
|
||||
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let style = measurementCSS(for: presentation)
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
@@ -185,7 +176,6 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
|
||||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
||||
viewportWidth: String,
|
||||
viewportHeight: String,
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
// RDEPUBTextAnchor.swift
|
||||
// EPUB 文本锚点和范围锚点
|
||||
// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID),
|
||||
// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。
|
||||
// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 文本锚点,精确定位到 EPUB 中的某个字符位置
|
||||
/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID
|
||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
/// 文件索引(等同于 spineIndex)
|
||||
|
||||
public let fileIndex: Int
|
||||
/// 行号
|
||||
|
||||
public let row: Int
|
||||
/// 列号(行内偏移)
|
||||
|
||||
public let column: Int
|
||||
/// 章节内的绝对字符偏移量
|
||||
|
||||
public let chapterOffset: Int
|
||||
/// 最近的 fragment ID(用于 URL 锚点定位)
|
||||
|
||||
public let fragmentID: String?
|
||||
|
||||
/// spine 索引的别名(与 fileIndex 等价)
|
||||
public var spineIndex: Int { fileIndex }
|
||||
|
||||
public init(
|
||||
@@ -67,11 +59,10 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本范围锚点,由起止锚点组成的区间
|
||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
/// 起始锚点
|
||||
|
||||
public let start: RDEPUBTextAnchor
|
||||
/// 结束锚点
|
||||
|
||||
public let end: RDEPUBTextAnchor
|
||||
|
||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
||||
@@ -79,7 +70,6 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(基于 chapterOffset)
|
||||
public var nsRange: NSRange {
|
||||
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// RDEPUBWebView+Configuration.swift
|
||||
// RDEPUBWebView 的 WKWebView 配置扩展
|
||||
// 负责按需创建和配置 WKWebView 实例:注册 JS 桥接消息处理器、
|
||||
// 注入用户脚本、设置自定义 URL Scheme 处理器、配置滚动和外观属性。
|
||||
// 同时处理选择菜单(拷贝/高亮/批注)和 WebView 销毁清理。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建)
|
||||
|
||||
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
|
||||
let parser = publication.parser
|
||||
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
|
||||
@@ -73,18 +68,15 @@ extension RDEPUBWebView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||
}
|
||||
|
||||
/// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebView(self, didRequestSelectionAction: action)
|
||||
clearWebSelection()
|
||||
}
|
||||
|
||||
/// 清除 WebView 中的文本选择
|
||||
func clearWebSelection() {
|
||||
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
|
||||
}
|
||||
|
||||
/// 销毁 WebView,移除所有消息处理器和引用
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
if let webView {
|
||||
@@ -106,7 +98,7 @@ extension RDEPUBWebView {
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000")
|
||||
|
||||
var ss_hexString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// RDEPUBWebView+FixedLayout.swift
|
||||
// RDEPUBWebView 的固定版式渲染扩展
|
||||
// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、
|
||||
// 计算加载签名(去重)、设置固定版式就绪回退定时器等。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载固定版式的 spread 页面
|
||||
|
||||
public func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
@@ -28,7 +24,6 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView
|
||||
func handleFixedLayoutLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBFixedRenderRequest,
|
||||
@@ -55,11 +50,10 @@ extension RDEPUBWebView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||
webView.loadHTMLString(
|
||||
html,
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme):///")
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成固定版式加载签名(用于去重相同请求)
|
||||
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
||||
let searchSignature = [
|
||||
request.searchPresentation?.keyword ?? "",
|
||||
@@ -87,7 +81,6 @@ extension RDEPUBWebView {
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成)
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
||||
@@ -101,7 +94,6 @@ extension RDEPUBWebView {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
||||
}
|
||||
|
||||
/// 取消固定版式就绪回退定时器
|
||||
func cancelFixedLayoutReadyFallback() {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// RDEPUBWebView+JavaScriptBridge.swift
|
||||
// RDEPUBWebView 的 JS 桥接和导航委托扩展
|
||||
// 实现 WKNavigationDelegate 处理页面加载生命周期,
|
||||
// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪),
|
||||
// 并将内部链接和外部链接路由到委托方法。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 从 URL 解析内部位置(ss-reader:// 协议的链接)
|
||||
|
||||
func internalLocation(from url: URL) -> RDEPUBLocation? {
|
||||
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
let fragment = url.fragment
|
||||
@@ -16,7 +11,6 @@ extension RDEPUBWebView {
|
||||
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
|
||||
}
|
||||
|
||||
/// 从 JS 消息体中构建当前位置对象
|
||||
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
||||
@@ -52,7 +46,6 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
}
|
||||
|
||||
/// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
|
||||
guard navigationAction.navigationType == .linkActivated,
|
||||
@@ -79,7 +72,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
}
|
||||
|
||||
extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
/// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法
|
||||
|
||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
|
||||
switch message.name {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// RDEPUBWebView+Reflowable.swift
|
||||
// RDEPUBWebView 的重排模式渲染扩展
|
||||
// 处理重排(Reflowable)模式的加载流程:构建渲染请求、
|
||||
// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、
|
||||
// 处理重复请求跳过和已有文档复用等优化逻辑。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载重排模式的指定页面
|
||||
|
||||
public func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@@ -46,7 +41,6 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式
|
||||
func handleReflowableLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBReflowableRenderRequest,
|
||||
@@ -91,7 +85,6 @@ extension RDEPUBWebView {
|
||||
webView.load(URLRequest(url: requestURL))
|
||||
}
|
||||
|
||||
/// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重)
|
||||
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let targetSignature = [
|
||||
request.targetLocation?.href ?? "",
|
||||
@@ -139,7 +132,6 @@ extension RDEPUBWebView {
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成
|
||||
func applyPresentation() {
|
||||
guard let webView, let currentRenderRequest else { return }
|
||||
guard case .reflowable(let request) = currentRenderRequest else {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// RDEPUBWebView+Search.swift
|
||||
// RDEPUBWebView 的搜索高亮扩展
|
||||
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
@@ -14,7 +11,6 @@ extension RDEPUBWebView {
|
||||
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
}
|
||||
|
||||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
completion?()
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
// RDEPUBWebView.swift
|
||||
// EPUB WebView 主类
|
||||
// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。
|
||||
// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、
|
||||
// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。
|
||||
// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
|
||||
public protocol RDEPUBWebViewDelegate: AnyObject {
|
||||
/// 阅读位置更新事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// 文本选择变化事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 选择菜单动作请求事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 内部链接点击事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 外部链接点击事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
|
||||
/// JavaScript 错误事件
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||
/// 页面渲染完成事件
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
||||
}
|
||||
|
||||
/// 委托协议的默认实现(选择菜单动作为可选)
|
||||
public extension RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
|
||||
}
|
||||
|
||||
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
|
||||
final class RDEPUBAnnotationWebView: WKWebView {
|
||||
/// 选择菜单动作回调
|
||||
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
@@ -64,63 +55,60 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
|
||||
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
|
||||
public final class RDEPUBWebView: UIView {
|
||||
/// 委托对象,接收渲染和交互事件
|
||||
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
/// 渲染完成回调(与 delegate 并行使用)
|
||||
|
||||
public var onRendered: (() -> Void)?
|
||||
/// 内部原生装饰解析回调
|
||||
|
||||
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
|
||||
|
||||
/// 当前关联的出版物
|
||||
var publication: RDEPUBPublication?
|
||||
/// 当前渲染请求
|
||||
|
||||
var currentRenderRequest: RDEPUBRenderRequest?
|
||||
/// 内部 WKWebView 实例
|
||||
|
||||
var webView: WKWebView?
|
||||
/// URL Scheme 处理器
|
||||
|
||||
var schemeHandler: RDEPUBResourceURLSchemeHandler?
|
||||
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
|
||||
|
||||
var configuredPublicationKey: String?
|
||||
/// 当前加载签名(用于去重相同请求)
|
||||
|
||||
var currentLoadSignature: String?
|
||||
/// 当前 spine 索引
|
||||
|
||||
var currentSpineIndex = 0
|
||||
/// 当前资源 href
|
||||
|
||||
var currentHref = ""
|
||||
/// 当前页码(从 0 开始)
|
||||
|
||||
var currentPageIndex = 0
|
||||
/// 当前章节总页数
|
||||
|
||||
var currentTotalPagesInChapter = 1
|
||||
/// 当前视口尺寸
|
||||
|
||||
var viewportSize: CGSize = .zero
|
||||
/// 当前内容内边距
|
||||
|
||||
var currentPadding: UIEdgeInsets = .zero
|
||||
/// 当前字号
|
||||
|
||||
var currentFontSize: CGFloat = 16
|
||||
/// 当前行高倍数
|
||||
|
||||
var currentLineHeightMultiple: CGFloat = 1.5
|
||||
/// 当前主题背景色
|
||||
|
||||
var currentThemeBackgroundColor: String?
|
||||
/// 当前主题文字色
|
||||
|
||||
var currentThemeTextColor: String?
|
||||
/// 目标跳转位置
|
||||
|
||||
var targetLocation: RDEPUBLocation?
|
||||
/// 待应用的高亮列表
|
||||
|
||||
var pendingHighlights: [RDEPUBHighlight] = []
|
||||
/// 当前固定版式 spread(固定版式时非空)
|
||||
|
||||
var fixedSpread: EPUBFixedSpread?
|
||||
/// 是否为固定版式模式
|
||||
|
||||
var isFixedLayout = false
|
||||
/// 是否有待处理的进度汇报请求
|
||||
|
||||
var pendingProgressionRequest = false
|
||||
/// 固定版式就绪回退定时器
|
||||
|
||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
/// 当前请求是否已完成渲染
|
||||
|
||||
var didRenderCurrentRequest = false
|
||||
/// 调试日志作用域
|
||||
|
||||
let debugScope = "ReaderWebView"
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@@ -141,7 +129,6 @@ public final class RDEPUBWebView: UIView {
|
||||
webView?.frame = bounds
|
||||
}
|
||||
|
||||
/// 重置所有状态并销毁 WebView
|
||||
public func reset() {
|
||||
delegate = nil
|
||||
onRendered = nil
|
||||
@@ -167,7 +154,6 @@ public final class RDEPUBWebView: UIView {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
|
||||
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
configureWebViewIfNeeded(publication: publication)
|
||||
guard let webView else { return }
|
||||
@@ -194,7 +180,6 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成页面加载签名
|
||||
func pageLoadSignature(
|
||||
publicationKey: String,
|
||||
request: RDEPUBRenderRequest
|
||||
@@ -207,7 +192,6 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
|
||||
func rendered() {
|
||||
guard !didRenderCurrentRequest else { return }
|
||||
didRenderCurrentRequest = true
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
// RDEPUBWebViewDebug.swift
|
||||
// WebView 调试日志工具
|
||||
// 提供统一的调试日志输出接口,支持导航事件、JS 执行、
|
||||
// 消息接收、URL Scheme 任务等场景的日志记录。
|
||||
// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// WebView 调试日志工具集
|
||||
enum RDEPUBWebViewDebug {
|
||||
/// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖)
|
||||
|
||||
static var isEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
|
||||
return configured
|
||||
@@ -21,8 +15,6 @@ enum RDEPUBWebViewDebug {
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启)
|
||||
/// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度
|
||||
static var isVerboseEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
|
||||
return configured
|
||||
@@ -30,8 +22,6 @@ enum RDEPUBWebViewDebug {
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 是否允许开启 inspectable。
|
||||
/// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。
|
||||
static var isInspectableEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
|
||||
return configured
|
||||
@@ -39,25 +29,21 @@ enum RDEPUBWebViewDebug {
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。
|
||||
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
|
||||
isInspectableEnabled = inspectableEnabled
|
||||
isVerboseEnabled = verboseLoggingEnabled
|
||||
}
|
||||
|
||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
return String(ObjectIdentifier(webView).hashValue, radix: 16)
|
||||
}
|
||||
|
||||
/// 输出通用日志
|
||||
static func log(_ scope: String, message: String) {
|
||||
guard isEnabled else { return }
|
||||
print("[RDReaderWK][\(scope)] \(message)")
|
||||
}
|
||||
|
||||
/// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息)
|
||||
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let webViewToken = webViewID(webView)
|
||||
@@ -69,13 +55,11 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 JavaScript 执行日志
|
||||
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
if isVerboseEnabled {
|
||||
@@ -97,7 +81,6 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let requestText = summarizedURL(requestURL)
|
||||
@@ -109,7 +92,6 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件)
|
||||
static func summarizedURL(_ url: URL?) -> String {
|
||||
guard let url else { return "nil" }
|
||||
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
|
||||
@@ -89,6 +89,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
function parseCssPixelValue(value) {
|
||||
if (!value) { return 0; }
|
||||
var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/);
|
||||
return match ? Number.parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
function sizeFromElement(element) {
|
||||
if (!element) { return null; }
|
||||
var width = parseCssPixelValue(element.style && element.style.width);
|
||||
var height = parseCssPixelValue(element.style && element.style.height);
|
||||
if (!width || !height) {
|
||||
width = parseCssPixelValue(element.getAttribute && element.getAttribute('width'));
|
||||
height = parseCssPixelValue(element.getAttribute && element.getAttribute('height'));
|
||||
}
|
||||
if (!width || !height) {
|
||||
var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
|
||||
if (rect) {
|
||||
width = width || rect.width;
|
||||
height = height || rect.height;
|
||||
}
|
||||
}
|
||||
if (width > 1 && height > 1) {
|
||||
return { width: width, height: height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePageSizeFromDocumentBox(iframe) {
|
||||
try {
|
||||
var doc = iframe.contentWindow.document;
|
||||
return sizeFromElement(doc.body) ||
|
||||
sizeFromElement(doc.documentElement) ||
|
||||
sizeFromElement(doc.querySelector('[id$="_hype_container"]')) ||
|
||||
sizeFromElement(doc.querySelector('iframe')) ||
|
||||
null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSizeFromNestedFrame(iframe) {
|
||||
try {
|
||||
var nestedFrame = iframe.contentWindow.document.querySelector('iframe');
|
||||
if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) {
|
||||
return null;
|
||||
}
|
||||
return parsePageSizeFromViewportMetaTag(nestedFrame) ||
|
||||
parsePageSizeFromDocumentBox(nestedFrame) ||
|
||||
parsePageSizeFromEmbeddedImage(nestedFrame);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageSizeFromEmbeddedImage(iframe) {
|
||||
try {
|
||||
var img = iframe.contentWindow.document.querySelector('img');
|
||||
@@ -176,9 +230,14 @@
|
||||
function onLoad() {
|
||||
iframe.__ssPageSize =
|
||||
parsePageSizeFromViewportMetaTag(iframe) ||
|
||||
parsePageSizeFromDocumentBox(iframe) ||
|
||||
parsePageSizeFromNestedFrame(iframe) ||
|
||||
parsePageSizeFromEmbeddedImage(iframe) ||
|
||||
pageViewportSize(iframe);
|
||||
layoutPage(iframe);
|
||||
window.setTimeout(function() {
|
||||
layoutPage(iframe);
|
||||
}, 250);
|
||||
finalizeLoad();
|
||||
}
|
||||
|
||||
@@ -204,4 +263,4 @@
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+7
-1
@@ -1,16 +1,18 @@
|
||||
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") })
|
||||
@@ -22,6 +24,7 @@ struct RDEPUBBuildDiagnosticsReporter {
|
||||
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)
|
||||
}
|
||||
@@ -38,11 +41,14 @@ struct RDEPUBBuildDiagnosticsReporter {
|
||||
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))
|
||||
)
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,12 +1,13 @@
|
||||
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
|
||||
@@ -16,10 +17,12 @@ struct RDEPUBChapterTailNormalizer {
|
||||
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 {
|
||||
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
#endif
|
||||
@@ -73,6 +76,7 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
|
||||
guard trailingFrame.contentRange.length > 0,
|
||||
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
||||
return false
|
||||
@@ -80,6 +84,7 @@ struct RDEPUBChapterTailNormalizer {
|
||||
|
||||
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 {
|
||||
@@ -94,6 +99,7 @@ struct RDEPUBChapterTailNormalizer {
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
) -> RDEPUBTextLayoutFrame {
|
||||
|
||||
let mergedRange = NSRange(
|
||||
location: previousFrame.contentRange.location,
|
||||
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
||||
@@ -109,6 +115,7 @@ struct RDEPUBChapterTailNormalizer {
|
||||
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"]
|
||||
@@ -121,6 +128,7 @@ struct RDEPUBChapterTailNormalizer {
|
||||
) -> 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)
|
||||
|
||||
+4
-1
@@ -1,8 +1,9 @@
|
||||
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) {
|
||||
@@ -32,11 +33,13 @@ struct RDEPUBPaginationCacheCoordinator {
|
||||
|
||||
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)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 文本书籍构建器,负责将 EPUB publication 渲染、分页并组装为 `RDEPUBTextBook`。
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
@@ -12,20 +11,14 @@ public final class RDEPUBTextBookBuilder {
|
||||
private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
|
||||
private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
|
||||
|
||||
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
||||
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
|
||||
|
||||
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
|
||||
/// 最后一次构建的性能采样数据
|
||||
|
||||
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 最后一次构建的缓存命中/未命中统计
|
||||
|
||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||
|
||||
/// 创建书籍构建器。
|
||||
/// - Parameters:
|
||||
/// - renderer: 文本渲染器
|
||||
/// - cache: 分页缓存(可选)
|
||||
/// - layoutConfig: 页面布局配置
|
||||
public init(
|
||||
renderer: RDEPUBTextRenderer,
|
||||
cache: RDEPUBTextBookCache? = nil,
|
||||
@@ -42,7 +35,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
|
||||
}
|
||||
|
||||
/// 默认构造器,使用 DTCoreText 渲染器
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
@@ -51,7 +43,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
||||
}
|
||||
|
||||
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
diagnosticsReporter.phase7SemanticSummary(
|
||||
title: title,
|
||||
@@ -59,16 +50,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/// 核心构建方法:从 EPUB publication 构建分页书籍。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 生成缓存键,尝试加载分页缓存
|
||||
/// 2. 遍历 spine 中的线性 HTML 章节
|
||||
/// 3. 渲染每章 HTML → NSAttributedString
|
||||
/// 4. 跳过空白的封面/扉页章节
|
||||
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
|
||||
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -88,7 +69,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
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)
|
||||
@@ -134,7 +114,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
||||
|
||||
#if DEBUG
|
||||
@@ -145,7 +124,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
/// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。
|
||||
public func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -192,6 +170,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
spineIndex: spineIndex,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
@@ -281,11 +260,13 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
@@ -339,6 +320,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
cfiMap: RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: item.href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterAttributedContent.string,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
),
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
@@ -365,9 +352,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -378,16 +362,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
/// 递归展开嵌套目录为扁平列表
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节过滤
|
||||
|
||||
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
var hasAttachment = false
|
||||
@@ -404,9 +384,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - 附件统计
|
||||
|
||||
/// 统计富文本中的附件数量
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@@ -418,7 +395,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
/// 获取富文本中所有附件的 NSRange 列表
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
@@ -430,9 +406,6 @@ public final class RDEPUBTextBookBuilder {
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - 封面章节检测
|
||||
|
||||
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
@@ -445,6 +418,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
|
||||
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
|
||||
|
||||
let collapsed = content.string
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\r", with: " ")
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
|
||||
/// 单个章节的分页元数据缓存,用于磁盘持久化。
|
||||
///
|
||||
/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
|
||||
/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
|
||||
public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
/// 章节文件相对路径
|
||||
|
||||
public var href: String
|
||||
/// 每页在章节富文本中的字符范围
|
||||
|
||||
public var pageRanges: [NSRange]
|
||||
/// 每页的分页原因(语义边界、帧限制等)
|
||||
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
public init(
|
||||
@@ -29,11 +24,8 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSCoding 归档层(只使用字符串和整数类型)
|
||||
|
||||
/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。
|
||||
/// 包含所有章节的分页归档数据。
|
||||
final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
let chapters: [ChapterPaginationArchive]
|
||||
@@ -52,20 +44,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
|
||||
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
let href: String
|
||||
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
|
||||
|
||||
let rangeLocations: [NSNumber]
|
||||
/// 页范围的长度数组
|
||||
|
||||
let rangeLengths: [NSNumber]
|
||||
/// 分页原因的原始字符串数组
|
||||
|
||||
let breakReasons: [String]
|
||||
/// 语义提示的原始字符串数组
|
||||
|
||||
let semanticHints: [String]
|
||||
|
||||
/// 从缓存模型构建归档对象
|
||||
init(from cache: RDEPUBTextChapterPaginationCache) {
|
||||
self.href = cache.href
|
||||
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
|
||||
@@ -97,7 +89,6 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
|
||||
/// 将归档数据转换回缓存模型
|
||||
func toCache() -> RDEPUBTextChapterPaginationCache {
|
||||
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
|
||||
NSRange(location: loc.intValue, length: len.intValue)
|
||||
@@ -111,28 +102,14 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页缓存管理器
|
||||
|
||||
/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
|
||||
///
|
||||
/// 缓存策略:
|
||||
/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
|
||||
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
|
||||
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
|
||||
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
/// 缓存模式版本号,变更时旧缓存自动失效
|
||||
// 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。
|
||||
public var schemaVersion: Int = 6
|
||||
|
||||
/// 串行队列,保证缓存读写的线程安全
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||
/// 缓存文件目录
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
/// 初始化缓存管理器,自动创建缓存目录
|
||||
/// - Parameter subdirectory: Caches 目录下的子目录名
|
||||
public init(subdirectory: String = "RDEPUBTextBookCache") {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
@@ -141,13 +118,6 @@ public final class RDEPUBTextBookCache {
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键生成
|
||||
// 对标 WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
|
||||
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
|
||||
///
|
||||
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
@@ -162,10 +132,6 @@ public final class RDEPUBTextBookCache {
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
// MARK: - 加载/保存(只缓存分页元数据)
|
||||
|
||||
/// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
|
||||
/// 缓存未命中或反序列化失败时返回 nil。
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@@ -203,7 +169,6 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将分页元数据保存到磁盘(原子写入,防止损坏)
|
||||
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@@ -223,9 +188,6 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 缓存失效
|
||||
|
||||
/// 清除所有缓存文件
|
||||
public func invalidateAll() {
|
||||
queue.sync {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
@@ -1,93 +1,99 @@
|
||||
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: - 章节构建结果
|
||||
|
||||
/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。
|
||||
public struct RDEPUBTextChapterBuildResult {
|
||||
|
||||
public var chapter: RDEPUBTextChapter
|
||||
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
|
||||
|
||||
public var performanceSample: RDEPUBTextPerformanceSample
|
||||
|
||||
public var cacheHit: Bool
|
||||
}
|
||||
|
||||
// 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 cfiMap: RDEPUBCFIMap?
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -102,31 +108,26 @@ public struct RDEPUBTextBook {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
/// 按 href 获取章节的数据访问层(包含索引表)
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按 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,
|
||||
@@ -138,7 +139,6 @@ public struct RDEPUBTextBook {
|
||||
return chapterData(for: normalizedLocation.href)
|
||||
}
|
||||
|
||||
/// 全书章节信息快照;对标 WXRead 由章节模型直接提供章节元数据。
|
||||
public var chapterInfos: [EPUBChapterInfo] {
|
||||
chapters.map { chapter in
|
||||
EPUBChapterInfo(
|
||||
@@ -149,7 +149,6 @@ public struct RDEPUBTextBook {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
@@ -157,12 +156,6 @@ public struct RDEPUBTextBook {
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
/// 根据持久化位置计算对应页码(从 1 开始)。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor 锚点定位
|
||||
/// 2. fragment 片段 ID
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapterData = chapterData(for: normalizedLocation.href) else {
|
||||
@@ -182,7 +175,6 @@ public struct RDEPUBTextBook {
|
||||
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 {
|
||||
|
||||
+1
-19
@@ -1,10 +1,8 @@
|
||||
// RDEPUBTextBuildPipelineInterfaces.swift
|
||||
// EPUB 文本构建管线接口定义,包括全书构建、章节渲染与分页管线。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// EPUB 全书文本构建接口,将 EPUB 出版物解析为可分页的文本书籍。
|
||||
protocol RDEPUBTextBookBuilding {
|
||||
|
||||
func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -13,41 +11,25 @@ protocol RDEPUBTextBookBuilding {
|
||||
) throws -> RDEPUBTextBook
|
||||
}
|
||||
|
||||
/// 章节渲染管线,将章节渲染请求委托给底层文本渲染器。
|
||||
struct RDEPUBChapterRenderPipeline {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
/// 初始化渲染管线。
|
||||
/// - Parameter renderer: 文本渲染器实例
|
||||
init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
/// 渲染单个章节,返回渲染后的富文本内容。
|
||||
/// - Parameter request: 章节渲染请求
|
||||
/// - Returns: 渲染完成的章节内容
|
||||
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
|
||||
try renderer.renderChapter(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节分页管线,将渲染后的富文本按页面尺寸拆分为文本帧数组。
|
||||
struct RDEPUBChapterPaginationPipeline {
|
||||
private let frameFactory: RDEPUBPageFrameBuilding
|
||||
|
||||
/// 初始化分页管线。
|
||||
/// - Parameter frameFactory: 页面帧工厂,默认使用 CoreText 实现
|
||||
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
|
||||
self.frameFactory = frameFactory
|
||||
}
|
||||
|
||||
/// 将富文本内容分页,返回页面帧数组。
|
||||
/// - Parameters:
|
||||
/// - content: 待分页的富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 页面帧数组
|
||||
func frames(
|
||||
for content: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
|
||||
+8
-21
@@ -1,20 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 性能采样数据模型
|
||||
|
||||
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
|
||||
public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
/// 章节文件路径
|
||||
|
||||
public var chapterHref: String
|
||||
/// HTML 渲染耗时(秒)
|
||||
|
||||
public var renderDuration: TimeInterval
|
||||
/// CoreText 分页耗时(秒)
|
||||
|
||||
public var paginateDuration: TimeInterval
|
||||
/// 分页后的页数
|
||||
|
||||
public var pageCount: Int
|
||||
/// 富文本字符长度
|
||||
|
||||
public var attributedStringLength: Int
|
||||
/// 是否命中分页缓存
|
||||
|
||||
public var cacheHit: Bool
|
||||
|
||||
public init(
|
||||
@@ -34,21 +31,14 @@ public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 性能采样器
|
||||
|
||||
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
|
||||
///
|
||||
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
|
||||
/// 构建完成后输出汇总报告。
|
||||
public final class RDEPUBTextPerformanceSampler {
|
||||
/// 所有章节的采样数据列表
|
||||
|
||||
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 整本书构建的总耗时(秒)
|
||||
|
||||
public var totalBuildDuration: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
#if DEBUG
|
||||
@@ -56,7 +46,6 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
public func summary() -> String {
|
||||
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
|
||||
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
|
||||
@@ -64,13 +53,11 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
|
||||
}
|
||||
|
||||
/// 重置所有采样数据
|
||||
public func reset() {
|
||||
samples.removeAll()
|
||||
totalBuildDuration = 0
|
||||
}
|
||||
|
||||
/// 将秒转换为毫秒格式字符串
|
||||
private func formatMS(_ duration: TimeInterval) -> String {
|
||||
String(format: "%.0fms", duration * 1000)
|
||||
}
|
||||
|
||||
@@ -5,24 +5,20 @@ import UIKit
|
||||
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
|
||||
|
||||
private let framesetter: CTFramesetter
|
||||
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
init(factory: RDEPUBCoreTextPageFrameFactory) {
|
||||
@@ -35,7 +31,6 @@ struct RDEPUBChapterPageCounter {
|
||||
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 []
|
||||
@@ -48,8 +43,6 @@ struct RDEPUBChapterPageCounter {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - CoreText 分页路径(回退方案)
|
||||
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@@ -129,9 +122,8 @@ struct RDEPUBChapterPageCounter {
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard config.numberOfColumns == 1 else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
@@ -252,9 +244,6 @@ struct RDEPUBChapterPageCounter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - WXRead 分页对齐
|
||||
|
||||
/// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。
|
||||
private func proposedVisibleRange(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
|
||||
+6
-41
@@ -5,13 +5,14 @@ import UIKit
|
||||
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) {
|
||||
@@ -21,13 +22,10 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
|
||||
}
|
||||
|
||||
/// 协议要求的便捷初始化(使用默认配置)
|
||||
init() {
|
||||
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
|
||||
}
|
||||
|
||||
// MARK: - RDEPUBPageFrameBuilding 协议
|
||||
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
@@ -39,9 +37,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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 {
|
||||
@@ -55,9 +50,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - 行级裁剪
|
||||
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@@ -74,7 +66,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@@ -88,7 +79,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@@ -102,7 +93,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@@ -115,7 +105,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行(通用路径)。
|
||||
func trimmedRangeForAvoidPageBreakInside(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@@ -155,7 +144,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@@ -195,7 +183,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。
|
||||
func trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@@ -227,9 +214,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return adjusted
|
||||
}
|
||||
|
||||
// MARK: - 行信息提取
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
static func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
@@ -239,7 +223,7 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
|
||||
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
@@ -248,9 +232,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
}
|
||||
#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)
|
||||
@@ -260,7 +241,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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)
|
||||
@@ -268,7 +248,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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)
|
||||
@@ -276,7 +255,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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) }
|
||||
@@ -284,7 +262,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
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 []
|
||||
@@ -297,7 +274,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 获取指定位置的语义提示列表
|
||||
func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -307,8 +283,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
// MARK: - Widow / Orphan 控制
|
||||
|
||||
private func trimmedRangeAvoidingWidow(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@@ -408,7 +382,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return lineCount
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
@@ -425,7 +398,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
@@ -442,7 +414,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
@@ -457,7 +428,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
return []
|
||||
@@ -474,7 +444,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 将范围裁剪到 attributedString 的合法边界内。
|
||||
func clampedRange(_ range: NSRange) -> NSRange? {
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard attributedString.length > 0 else {
|
||||
@@ -486,7 +455,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
@@ -497,9 +465,6 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志
|
||||
func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
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
|
||||
@@ -35,7 +30,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 检查指定行范围是否落在 keepWithNext 保护块内。
|
||||
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
||||
return false
|
||||
@@ -54,14 +48,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - 范围调整调度器
|
||||
|
||||
/// 对 CoreText/DTCoreText 提出的分页范围进行语义边界调整。
|
||||
///
|
||||
/// 调整优先级:
|
||||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||||
/// 2. pageRelate 跨页关联边界
|
||||
/// 3. 以上都不满足时,使用原始帧限制分页
|
||||
func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
@@ -111,8 +97,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
|
||||
// 微信读书特有的跨页关联规则。
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: proposedRange.location + 1,
|
||||
@@ -142,7 +126,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
// 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@@ -164,9 +147,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界。
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
@@ -192,8 +172,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
// MARK: - 内部工具
|
||||
|
||||
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
||||
return false
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// CoreText 分页引擎产生的单帧(单页)布局数据。
|
||||
///
|
||||
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
|
||||
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
|
||||
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
|
||||
struct RDEPUBTextLayoutFrame: Equatable {
|
||||
/// 该帧在章节富文本中的字符范围
|
||||
|
||||
var contentRange: NSRange
|
||||
/// 分页原因(语义边界、附件边界、帧限制等)
|
||||
|
||||
var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 所属 HTML 块级元素的范围(用于调试)
|
||||
|
||||
var blockRange: NSRange?
|
||||
/// 帧内附件的字符范围列表
|
||||
|
||||
var attachmentRanges: [NSRange]
|
||||
/// 帧内附件类型列表(图片、通用附件等)
|
||||
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
|
||||
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
|
||||
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 帧内附件的布局方式(行内、基线、居中)
|
||||
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
|
||||
|
||||
var trailingFragmentID: String?
|
||||
/// 诊断日志列表(分页原因详情、范围信息等)
|
||||
|
||||
var diagnostics: [String]
|
||||
|
||||
/// 将布局帧数据转换为页元数据模型
|
||||
var metadata: RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: breakReason,
|
||||
|
||||
@@ -5,12 +5,6 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 分页引擎 Facade:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
|
||||
///
|
||||
/// 内部委托给三个组件:
|
||||
/// - RDEPUBCoreTextPageFrameFactory — 帧创建、属性查询、诊断
|
||||
/// - RDEPUBPageBreakPolicy — 分页规则(语义保护、keepWithNext)
|
||||
/// - RDEPUBChapterPageCounter — 顶层分页循环编排
|
||||
struct RDEPUBTextLayouter {
|
||||
private let counter: RDEPUBChapterPageCounter
|
||||
|
||||
@@ -19,7 +13,6 @@ struct RDEPUBTextLayouter {
|
||||
self.counter = RDEPUBChapterPageCounter(factory: factory)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
+5
-24
@@ -1,30 +1,18 @@
|
||||
// RDEPUBTextPaginationInterfaces.swift
|
||||
// EPUB 分页接口定义,包括断页决策、章节数页和页面帧构建协议。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - 分页接口定义
|
||||
|
||||
/// 单次断页决策,记录断页位置、原因及诊断信息。
|
||||
struct RDEPUBPageBreakDecision {
|
||||
/// 本页在富文本中的字符范围
|
||||
|
||||
var range: NSRange
|
||||
/// 断页原因
|
||||
|
||||
var reason: RDEPUBTextPageBreakReason
|
||||
/// 分页过程中的诊断日志
|
||||
|
||||
var diagnostics: [String]
|
||||
}
|
||||
|
||||
/// 章节页数计算接口,将富文本按页面尺寸拆分为断页决策序列。
|
||||
protocol RDEPUBChapterPageCounting {
|
||||
/// 计算章节中每页的断页位置。
|
||||
/// - Parameters:
|
||||
/// - attributedString: 章节富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 断页决策数组,每个元素对应一页
|
||||
|
||||
func pageRanges(
|
||||
for attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
@@ -33,15 +21,8 @@ protocol RDEPUBChapterPageCounting {
|
||||
) -> [RDEPUBPageBreakDecision]
|
||||
}
|
||||
|
||||
/// 页面帧构建接口,将富文本转换为可渲染的页面帧数组。
|
||||
protocol RDEPUBPageFrameBuilding {
|
||||
/// 将富文本构建为页面帧数组。
|
||||
/// - Parameters:
|
||||
/// - attributedString: 待分页的富文本
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - config: 排版配置
|
||||
/// - fragmentOffsets: fragment 锚点偏移映射
|
||||
/// - Returns: 页面帧数组
|
||||
|
||||
func makeFrames(
|
||||
attributedString: NSAttributedString,
|
||||
pageSize: CGSize,
|
||||
|
||||
+2
-16
@@ -1,18 +1,8 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
// MARK: - NSAttributedString 分页扩展
|
||||
|
||||
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
|
||||
extension NSAttributedString {
|
||||
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
|
||||
/// - Parameters:
|
||||
/// - size: 页面尺寸
|
||||
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
|
||||
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
|
||||
/// - Returns: 分页后的布局帧列表
|
||||
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
@@ -23,17 +13,13 @@ extension NSAttributedString {
|
||||
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor CSS 转换扩展
|
||||
|
||||
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS rgba() 字符串格式
|
||||
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
|
||||
///
|
||||
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
|
||||
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
|
||||
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
|
||||
public final class RDEPUBChapterData {
|
||||
/// 底层章节模型(只读)
|
||||
|
||||
public let chapter: RDEPUBTextChapter
|
||||
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
|
||||
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
@@ -16,88 +11,66 @@ public final class RDEPUBChapterData {
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
// MARK: - 便捷属性(转发自 chapter)
|
||||
|
||||
/// 章节在全书中的序号
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
/// spine 顺序索引
|
||||
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
|
||||
|
||||
public var href: String { chapter.href }
|
||||
/// 章节标题
|
||||
|
||||
public var title: String { chapter.title }
|
||||
/// 章节完整的富文本内容
|
||||
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
/// 分页后的页面列表
|
||||
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
/// 章节总页数
|
||||
|
||||
public var pageCount: Int { chapter.pages.count }
|
||||
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
|
||||
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
/// 章节信息快照(供阅读会话/目录面板复用)
|
||||
|
||||
public var chapterInfo: EPUBChapterInfo {
|
||||
EPUBChapterInfo(spineIndex: spineIndex, title: title, pageCount: pageCount)
|
||||
}
|
||||
/// 章节覆盖的绝对页码范围(闭区间)
|
||||
|
||||
public var absolutePageRange: ClosedRange<Int>? {
|
||||
guard let firstPage = pages.first, let lastPage = pages.last else { return nil }
|
||||
return firstPage.absolutePageIndex...lastPage.absolutePageIndex
|
||||
}
|
||||
|
||||
// MARK: - 页面查询
|
||||
|
||||
/// 查找包含指定绝对字符偏移量的页面
|
||||
/// - Parameter absoluteOffset: 全书绝对字符偏移量
|
||||
/// - Returns: 包含该偏移量的页面,未找到返回 nil
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
/// 按绝对页码查找页面
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
/// 按章节内页码查找页面(从 1 开始)
|
||||
public func page(atPageNumber pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else { return nil }
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
// MARK: - 锚点与位置映射
|
||||
|
||||
/// 将章节内字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
/// 将全书字符索引转换为语义锚点。
|
||||
public func anchor(forGlobalIndex index: Int) -> RDEPUBTextAnchor? {
|
||||
indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
/// 将章节内字符范围转换为起止锚点对
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
/// 将锚点范围转换为全书字符范围。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
|
||||
/// - Parameters:
|
||||
/// - absoluteRange: 选区在全书中的字符范围
|
||||
/// - bookIdentifier: 书籍标识符
|
||||
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
|
||||
@@ -115,7 +88,6 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
@@ -124,32 +96,31 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将页面转换为对应的 RDEPUBLocation
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据持久化位置定位所属页面。
|
||||
public func page(for location: RDEPUBLocation) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: location) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
/// 根据搜索结果定位所属页面。
|
||||
public func page(for searchMatch: RDEPUBSearchMatch) -> RDEPUBTextPage? {
|
||||
guard let range = absoluteRange(for: searchMatch) else { return nil }
|
||||
return page(containing: range.location)
|
||||
}
|
||||
|
||||
// MARK: - 位置反向解析(Location → 绝对偏移量)
|
||||
|
||||
/// 将持久化位置还原为章节内的字符范围。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor(锚点定位,最精确)
|
||||
/// 2. fragment(片段 ID 定位)
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.chapterOffset(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -164,16 +135,35 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = chapterRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let location = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: location,
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: location)
|
||||
)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||
}
|
||||
@@ -183,8 +173,17 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将持久化位置还原为全书字符范围。
|
||||
public func globalRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
return NSRange(location: indexTable.globalIndex(for: anchor), length: 1)
|
||||
}
|
||||
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -195,8 +194,16 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原全书字符范围。
|
||||
public func globalRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let endpointRange = globalRange(fromLocationCFIEndpoints: highlight.location) {
|
||||
return endpointRange
|
||||
}
|
||||
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -207,8 +214,19 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
/// 从搜索结果还原全书字符范围。
|
||||
public func globalRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(searchMatch.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(searchMatch.cfi),
|
||||
let anchor = indexTable.anchor(for: cfi) {
|
||||
let chapterLocation = indexTable.chapterOffset(for: anchor)
|
||||
return NSRange(
|
||||
location: indexTable.globalIndex(for: anchor),
|
||||
length: recoveredSearchRangeLength(for: searchMatch, cfi: cfi, startOffset: chapterLocation)
|
||||
)
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
@@ -219,13 +237,14 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: chapterStart + chapterRange.location, length: chapterRange.length)
|
||||
}
|
||||
|
||||
// MARK: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
guard highlight.location.href == chapter.href else { return false }
|
||||
if let cfiRange = RDEPUBCFICompatibility.parseRangeLossy(highlight.location.rangeCFI),
|
||||
let rangeAnchor = indexTable.rangeAnchor(for: cfiRange) {
|
||||
return NSIntersectionRange(indexTable.chapterRange(for: rangeAnchor), page.contentRange).length > 0
|
||||
}
|
||||
if let anchor = highlight.location.rangeAnchor?.start {
|
||||
return pageRange.contains(absoluteOffset(for: anchor))
|
||||
}
|
||||
@@ -236,7 +255,6 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
@@ -247,26 +265,20 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// `searchMatches(on:from:)` 的别名;保持和文档/WXRead 语义一致。
|
||||
public func searchResults(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
searchMatches(on: page, from: matches)
|
||||
}
|
||||
|
||||
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
guard let page = page(for: location) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
/// 根据搜索结果获取对应页码(从 1 开始)。
|
||||
public func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let page = page(for: searchMatch) else { return nil }
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
// MARK: - 目录与章节语义
|
||||
|
||||
/// 判断某个 TOC 条目是否属于当前章节。
|
||||
public func contains(
|
||||
tableOfContentsItem item: EPUBTableOfContentsItem,
|
||||
normalizer: (String) -> String?
|
||||
@@ -278,7 +290,6 @@ public final class RDEPUBChapterData {
|
||||
return chapterHref == itemHref
|
||||
}
|
||||
|
||||
/// 过滤出当前章节命中的目录条目。
|
||||
public func tableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
@@ -289,7 +300,6 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前章节最合适的目录条目(通常取命中的首个最近条目)。
|
||||
public func primaryTableOfContentsItem(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
normalizer: (String) -> String?
|
||||
@@ -297,10 +307,6 @@ public final class RDEPUBChapterData {
|
||||
tableOfContentsItems(from: items, normalizer: normalizer).first
|
||||
}
|
||||
|
||||
// MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:key:itemId:color:)
|
||||
|
||||
/// 将高亮/下划线作为自定义属性注入 NSAttributedString,
|
||||
/// 供 CoreText 渲染时读取绘制(对齐 WXRead 的 com.weread.highlight / com.weread.underline)
|
||||
public func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
@@ -324,17 +330,51 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 私有工具方法
|
||||
|
||||
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为章节内字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.chapterOffset(for: anchor)
|
||||
}
|
||||
|
||||
private func chapterRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.chapterOffset(for: startAnchor)
|
||||
let end = indexTable.chapterOffset(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func globalRange(fromLocationCFIEndpoints location: RDEPUBLocation) -> NSRange? {
|
||||
guard let startCFI = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let endCFI = RDEPUBCFICompatibility.parseLossy(location.lastCFI ?? location.cfi),
|
||||
let startAnchor = indexTable.anchor(for: startCFI),
|
||||
let endAnchor = indexTable.anchor(for: endCFI),
|
||||
startAnchor.fileIndex == endAnchor.fileIndex else {
|
||||
return nil
|
||||
}
|
||||
let start = indexTable.globalIndex(for: startAnchor)
|
||||
let end = indexTable.globalIndex(for: endAnchor)
|
||||
return NSRange(location: min(start, end), length: max(abs(end - start), 1))
|
||||
}
|
||||
|
||||
private func recoveredSearchRangeLength(
|
||||
for searchMatch: RDEPUBSearchMatch,
|
||||
cfi: RDEPUBCFI,
|
||||
startOffset: Int
|
||||
) -> Int {
|
||||
let exactLength = cfi.textAssertion?.exact?.utf16.count ?? 0
|
||||
let fallbackLength = max(searchMatch.rangeLength, 1)
|
||||
let candidateLength = exactLength > 0 ? exactLength : fallbackLength
|
||||
let remainingLength = max(attributedContent.length - startOffset, 1)
|
||||
return min(max(candidateLength, 1), remainingLength)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,9 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
|
||||
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
|
||||
///
|
||||
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
|
||||
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
|
||||
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
/// DTCoreText 库是否在当前编译环境中可用
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
@@ -24,7 +15,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
@@ -56,7 +46,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@@ -65,6 +54,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: "",
|
||||
spineIndex: nil,
|
||||
title: "",
|
||||
rawHTML: html,
|
||||
baseURL: baseURL,
|
||||
@@ -75,7 +65,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return try renderChapter(request: request)
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
@@ -93,7 +82,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
|
||||
|
||||
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
@@ -111,7 +100,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let maxImageSize = resolvedMaxImageSize(for: request)
|
||||
|
||||
@@ -1,84 +1,46 @@
|
||||
// RDEPUBTextPositionConverter.swift
|
||||
// EPUB 全书文本位置转换器,在多种坐标体系之间进行双向映射。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 对标 WXRead `WREpubPositionConverter` 的全书位置转换器。
|
||||
///
|
||||
/// 负责在四套坐标之间做双向转换:
|
||||
/// - `(fileIndex, row, column)` 文件级语义锚点
|
||||
/// - 章节内字符偏移
|
||||
/// - 全书字符偏移
|
||||
/// - 页码 / `RDEPUBLocation`
|
||||
public struct RDEPUBTextPositionConverter {
|
||||
/// 当前转换器所关联的 EPUB 文本对象。
|
||||
|
||||
public let book: RDEPUBTextBook
|
||||
|
||||
/// 创建指定 EPUB 文本对象的位置转换器。
|
||||
/// - Parameter book: 要关联的 EPUB 文本对象。
|
||||
public init(book: RDEPUBTextBook) {
|
||||
self.book = book
|
||||
}
|
||||
|
||||
/// 全书字符总数(跨所有文件累计)。
|
||||
public var totalCharacterCount: Int {
|
||||
book.indexTable.totalCharacterCount
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为全书字符偏移。
|
||||
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - Returns: 对应的全书字符偏移量。
|
||||
public func globalIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
book.indexTable.globalIndex(for: anchor)
|
||||
}
|
||||
|
||||
/// 将语义范围锚点转换为全书字符范围。
|
||||
/// - Parameter rangeAnchor: 由起止锚点组成的语义范围。
|
||||
/// - Returns: 对应的全书字符范围(`NSRange`)。
|
||||
public func globalRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
book.indexTable.globalRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移查找所属文件的索引。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的文件索引,超出范围时返回 `nil`。
|
||||
public func fileIndex(forCharacterPosition index: Int) -> Int? {
|
||||
book.indexTable.fileIndex(forCharacterPosition: index)
|
||||
}
|
||||
|
||||
/// 将全书字符偏移转换为指定文件内的本地偏移。
|
||||
/// - Parameters:
|
||||
/// - fileIndex: 目标文件索引。
|
||||
/// - index: 全书字符偏移量。
|
||||
/// - Returns: 文件内的本地字符偏移,越界时返回 `nil`。
|
||||
public func localOffsetInFile(at fileIndex: Int, forGlobalPosition index: Int) -> Int? {
|
||||
book.indexTable.localOffsetInFile(at: fileIndex, forGlobalPosition: index)
|
||||
}
|
||||
|
||||
/// 将全书字符偏移转换为语义锚点。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的语义锚点,无效位置时返回 `nil`。
|
||||
public func anchor(forCharacterPosition index: Int) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(forGlobalIndex: index)
|
||||
}
|
||||
|
||||
/// 将 `RDEPUBLocation` 转换为语义锚点。
|
||||
/// - Parameter location: EPUB 位置对象。
|
||||
/// - Returns: 对应的语义锚点,无法映射时返回 `nil`。
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
book.indexTable.anchor(for: location)
|
||||
}
|
||||
|
||||
/// 根据语义锚点获取页码(从 1 开始)。
|
||||
/// - Parameter anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - Returns: 对应的页码,无法定位时返回 `nil`。
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor) -> Int? {
|
||||
book.indexTable.pageNumber(for: anchor, in: book).map { $0 + 1 }
|
||||
}
|
||||
|
||||
/// 根据全书字符偏移获取页码(从 1 开始)。
|
||||
/// - Parameter index: 全书字符偏移量。
|
||||
/// - Returns: 对应的页码,无效位置时返回 `nil`。
|
||||
public func pageNumber(forCharacterPosition index: Int) -> Int? {
|
||||
guard let anchor = anchor(forCharacterPosition: index) else {
|
||||
return nil
|
||||
@@ -86,11 +48,6 @@ public struct RDEPUBTextPositionConverter {
|
||||
return pageNumber(for: anchor)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为可序列化的 `RDEPUBLocation`。
|
||||
/// - Parameters:
|
||||
/// - anchor: 由文件索引、行号、列号组成的语义锚点。
|
||||
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
|
||||
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
bookIdentifier: String?
|
||||
@@ -101,11 +58,6 @@ public struct RDEPUBTextPositionConverter {
|
||||
return book.indexTable.location(for: anchor, in: chapter, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 将语义范围锚点转换为可序列化的 `RDEPUBLocation`。
|
||||
/// - Parameters:
|
||||
/// - rangeAnchor: 由起止锚点组成的语义范围。
|
||||
/// - bookIdentifier: 书籍标识符,会写入 location 的 `bookId` 字段。
|
||||
/// - Returns: 对应的 `RDEPUBLocation`,无法映射时返回 `nil`。
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
bookIdentifier: String?
|
||||
|
||||
@@ -1,71 +1,54 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 自定义富文本属性键
|
||||
|
||||
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
|
||||
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
|
||||
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
|
||||
public extension NSAttributedString.Key {
|
||||
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
|
||||
|
||||
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
|
||||
/// 块级元素在章节中的序号
|
||||
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||
/// fragment 锚点 ID
|
||||
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||
/// 附件类型(图片/通用附件)
|
||||
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
||||
/// 块级元素类型(段落/列表/表格/代码等)
|
||||
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
|
||||
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
|
||||
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
|
||||
/// 附件布局方式(行内/基线/居中)
|
||||
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
// MARK: - 块级元素类型枚举
|
||||
|
||||
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph // <p>
|
||||
case list // <ul>, <ol>, <li>
|
||||
case table // <table> 系列
|
||||
case code // <pre>, <code>
|
||||
case blockquote // <blockquote>
|
||||
case attachment // 附件块(图片、figure 等)
|
||||
case generic // 通用块(<div>, <h1>-<h6> 等)
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
}
|
||||
|
||||
// MARK: - 语义提示枚举
|
||||
|
||||
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
|
||||
case keepWithNext // 与下一段保持同页(如标题)
|
||||
case pageBreakBefore // 在此元素前强制分页
|
||||
case pageBreakAfter // 在此元素后强制分页
|
||||
case pageRelate // 微信读书式跨页关联元素
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
}
|
||||
|
||||
// MARK: - 附件布局方式枚举
|
||||
|
||||
/// 富文本附件(图片等)的垂直布局方式。
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline // 行内布局
|
||||
case baseline // 基线对齐
|
||||
case centered // 垂直居中(块级)
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
}
|
||||
|
||||
// MARK: - 渲染样式
|
||||
|
||||
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
|
||||
|
||||
public var font: UIFont
|
||||
/// 行间距(pt)
|
||||
|
||||
public var lineSpacing: CGFloat
|
||||
/// 文本颜色(nil 则使用 EPUB 原始颜色)
|
||||
|
||||
public var textColor: UIColor?
|
||||
/// 背景颜色(nil 则透明,用于暗色模式判断)
|
||||
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
@@ -76,31 +59,28 @@ public struct RDEPUBTextRenderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 布局配置
|
||||
|
||||
/// 分页引擎的布局控制参数。
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
/// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width
|
||||
|
||||
public var frameWidth: CGFloat
|
||||
/// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height
|
||||
|
||||
public var frameHeight: CGFloat
|
||||
/// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets
|
||||
|
||||
public var edgeInsets: UIEdgeInsets
|
||||
/// 栏数,对标 WXRead 的 numberOfColumns
|
||||
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,对标 WXRead 的 columnGap
|
||||
|
||||
public var columnGap: CGFloat
|
||||
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
||||
|
||||
public var avoidOrphans: Bool
|
||||
/// 是否避免寡行(段落第一行单独在上一页底部)
|
||||
|
||||
public var avoidWidows: Bool
|
||||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||||
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
|
||||
|
||||
public var hyphenation: Bool
|
||||
/// 图片最大高度占页面高度的比例
|
||||
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
/// 当调用方暂时拿不到 pageSize 时,用于估算附件尺寸的兜底 viewport。
|
||||
|
||||
public var fallbackViewportSize: CGSize
|
||||
|
||||
public init(
|
||||
@@ -129,10 +109,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
self.fallbackViewportSize = fallbackViewportSize
|
||||
}
|
||||
|
||||
/// 默认配置
|
||||
public static let `default` = RDEPUBTextLayoutConfig()
|
||||
|
||||
/// 结合调用方 pageSize 解析后的实际页面尺寸。
|
||||
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
|
||||
CGSize(
|
||||
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
|
||||
@@ -140,13 +118,11 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。
|
||||
public func contentRect(fallback pageSize: CGSize) -> CGRect {
|
||||
let size = resolvedFrameSize(fallback: pageSize)
|
||||
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
|
||||
}
|
||||
|
||||
/// 多栏布局时的列矩形数组。
|
||||
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
|
||||
let rect = contentRect(fallback: pageSize)
|
||||
let columns = max(1, numberOfColumns)
|
||||
@@ -161,7 +137,6 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化/缓存键使用的稳定签名。
|
||||
public var cacheSignature: String {
|
||||
[
|
||||
String(format: "%.3f", frameWidth),
|
||||
@@ -183,18 +158,14 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CSS 样式表层级
|
||||
|
||||
/// 样式表层级类型,用于 CSS 层叠优先级管理。
|
||||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||||
case `default` // 基础重置样式(margin、padding 等)
|
||||
case replace // 元素替换样式(图片居中、标题分页等)
|
||||
case dark // 暗色模式覆盖
|
||||
case epub // EPUB 原始样式表
|
||||
case user // 用户自定义样式(字号、行距、颜色等)
|
||||
case `default`
|
||||
case replace
|
||||
case dark
|
||||
case epub
|
||||
case user
|
||||
}
|
||||
|
||||
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
|
||||
public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
public var kind: RDEPUBTextStyleSheetLayerKind
|
||||
public var css: String
|
||||
@@ -205,9 +176,6 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
|
||||
///
|
||||
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
|
||||
public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
public var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
@@ -215,7 +183,6 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
/// 合并所有非空层的 CSS,每层添加注释头标记
|
||||
public var combinedCSS: String {
|
||||
layers
|
||||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
@@ -226,15 +193,11 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 资源引用诊断
|
||||
|
||||
/// 资源引用类型(样式表或图片)
|
||||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||||
case stylesheet
|
||||
case image
|
||||
}
|
||||
|
||||
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
|
||||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
public var kind: RDEPUBTextResourceReferenceKind
|
||||
public var chapterHref: String
|
||||
@@ -260,9 +223,6 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节渲染上下文与请求
|
||||
|
||||
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
|
||||
public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
@@ -270,6 +230,7 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var baseURL: URL?
|
||||
public var stylesheet: RDEPUBTextStyleSheetPackage
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
public var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
@@ -277,7 +238,8 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage,
|
||||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic],
|
||||
styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport()
|
||||
) {
|
||||
self.href = href
|
||||
self.title = title
|
||||
@@ -285,16 +247,16 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
self.baseURL = baseURL
|
||||
self.stylesheet = stylesheet
|
||||
self.resourceDiagnostics = resourceDiagnostics
|
||||
self.styleCompatibilityReport = styleCompatibilityReport
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
|
||||
public struct RDEPUBTextChapterRenderRequest {
|
||||
public var context: RDEPUBTextChapterContext
|
||||
public var style: RDEPUBTextRenderStyle
|
||||
/// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。
|
||||
|
||||
public var pageSize: CGSize?
|
||||
/// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。
|
||||
|
||||
public var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
|
||||
public init(
|
||||
@@ -310,15 +272,12 @@ public struct RDEPUBTextChapterRenderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染结果
|
||||
|
||||
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
/// 渲染后的富文本
|
||||
|
||||
public var attributedString: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射
|
||||
|
||||
public var fragmentOffsets: [String: Int]
|
||||
/// 资源引用诊断列表
|
||||
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public init(
|
||||
@@ -332,18 +291,12 @@ public struct RDEPUBRenderedChapterContent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染器协议
|
||||
|
||||
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
|
||||
///
|
||||
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
|
||||
public protocol RDEPUBTextRenderer {
|
||||
/// 渲染单个章节
|
||||
|
||||
func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
|
||||
/// 便捷方法:直接渲染 HTML 字符串
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@@ -351,7 +304,6 @@ public protocol RDEPUBTextRenderer {
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
/// 协议默认实现:将便捷方法委托给完整方法
|
||||
public extension RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
@@ -370,12 +322,9 @@ public extension RDEPUBTextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染错误
|
||||
|
||||
/// EPUB 文本渲染过程中可能出现的错误类型。
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
|
||||
case htmlImportFailed // HTML 富文本解析失败
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
|
||||
///
|
||||
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
|
||||
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
|
||||
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
@@ -14,14 +9,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 在全书中搜索关键词,返回所有匹配结果。
|
||||
///
|
||||
/// 搜索流程:
|
||||
/// 1. 遍历所有章节
|
||||
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
|
||||
/// 3. 计算匹配位置的阅读进度(progression)
|
||||
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
|
||||
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@@ -49,6 +36,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
@@ -57,7 +45,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
@@ -73,7 +63,6 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
return matches
|
||||
}
|
||||
|
||||
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
@@ -82,13 +71,16 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 无 publication 时的搜索(用于 txt 等外部文本文件)
|
||||
static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else { return [] }
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
let chapterData = RDEPUBChapterData(
|
||||
chapter: chapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [chapter])
|
||||
)
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { continue }
|
||||
@@ -102,6 +94,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start)
|
||||
let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor)
|
||||
let previewRadius = 12
|
||||
let previewStart = max(foundRange.location - previewRadius, 0)
|
||||
let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength)
|
||||
@@ -116,7 +111,9 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: nil
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: cfi?.rawValue ?? cfiRange?.start.rawValue,
|
||||
rangeCFI: cfiRange?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。
|
||||
///
|
||||
/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型,
|
||||
/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。
|
||||
///
|
||||
/// 处理流程:
|
||||
/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码)
|
||||
/// 2. 按中文章节正则拆分为多个章节
|
||||
/// 3. 将纯文本包装为 HTML 段落
|
||||
/// 4. 通过渲染器(DTCoreText)渲染为富文本
|
||||
/// 5. CoreText 分页 → RDEPUBTextBook
|
||||
public final class RDPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
@@ -23,13 +12,6 @@ public final class RDPlainTextBookBuilder {
|
||||
self.layoutConfig = layoutConfig
|
||||
}
|
||||
|
||||
/// 从纯文本文件构建分页书籍。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - textFileURL: 纯文本文件的本地 URL
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - style: 渲染样式(字体、行距、颜色)
|
||||
/// - Returns: 分页后的书籍模型
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
@@ -109,6 +91,7 @@ public final class RDPlainTextBookBuilder {
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: [:],
|
||||
cfiMap: nil,
|
||||
pageBreakReasons: pages.map { $0.metadata.breakReason },
|
||||
pages: pages
|
||||
)
|
||||
@@ -119,16 +102,11 @@ public final class RDPlainTextBookBuilder {
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
/// 章节规格:标题 + 内容
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
|
||||
/// 无匹配时将整篇文本作为一章处理。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
@@ -154,25 +132,18 @@ public final class RDPlainTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// 拆分结果为空(所有章节内容为空),整体作为一章
|
||||
if specs.isEmpty {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
return "<html><body>\(body)</body></html>"
|
||||
}
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBCSSCompatibilityLayer {
|
||||
|
||||
var policy = RDEPUBStyleCompatibilityPolicy()
|
||||
|
||||
func sanitize(_ css: String) -> RDEPUBCSSCompatibilityResult {
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
var unsupported: [String] = []
|
||||
|
||||
if policy.normalizeLineHeight {
|
||||
let result = clampNumericProperty(
|
||||
in: rewritten,
|
||||
property: "line-height",
|
||||
minValue: 0.9,
|
||||
maxValue: 2.4
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
if policy.normalizeTextIndent {
|
||||
let result = clampLengthProperty(
|
||||
in: rewritten,
|
||||
property: "text-indent",
|
||||
maxAbsolutePX: 64
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
if !policy.allowPublisherMargins {
|
||||
let result = clampLengthProperty(
|
||||
in: rewritten,
|
||||
property: "margin",
|
||||
maxAbsolutePX: 64
|
||||
)
|
||||
rewritten = result.css
|
||||
normalized += result.normalizedRules
|
||||
}
|
||||
|
||||
if policy.fallbackUnsupportedWritingModes,
|
||||
rewritten.range(of: "writing-mode", options: [.caseInsensitive]) != nil {
|
||||
unsupported.append("writing-mode")
|
||||
rewritten += "\n\nhtml, body { writing-mode: horizontal-tb !important; }"
|
||||
normalized.append("writing-mode")
|
||||
}
|
||||
|
||||
if policy.clampImagesToViewport {
|
||||
rewritten += """
|
||||
|
||||
img, svg, table, video, canvas {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
table {
|
||||
overflow-wrap: anywhere;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
"""
|
||||
normalized.append("media-size-clamp")
|
||||
}
|
||||
|
||||
return RDEPUBCSSCompatibilityResult(
|
||||
css: rewritten,
|
||||
report: RDEPUBCSSCompatibilityReport(
|
||||
unsupportedRules: unsupported,
|
||||
normalizedRules: normalized,
|
||||
fontFailures: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func clampNumericProperty(
|
||||
in css: String,
|
||||
property: String,
|
||||
minValue: Double,
|
||||
maxValue: Double
|
||||
) -> (css: String, normalizedRules: [String]) {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*([0-9]*\.?[0-9]+)\s*;"#
|
||||
) else {
|
||||
return (css, [])
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
|
||||
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
|
||||
guard let value = Double(rawValue), value < minValue || value > maxValue else { continue }
|
||||
|
||||
let clamped = min(max(value, minValue), maxValue)
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.3f", clamped));")
|
||||
normalized.append(property)
|
||||
}
|
||||
}
|
||||
return (rewritten, normalized)
|
||||
}
|
||||
|
||||
private func clampLengthProperty(
|
||||
in css: String,
|
||||
property: String,
|
||||
maxAbsolutePX: Double
|
||||
) -> (css: String, normalizedRules: [String]) {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: property) + #"\s*:\s*(-?[0-9]*\.?[0-9]+)px\s*;"#
|
||||
) else {
|
||||
return (css, [])
|
||||
}
|
||||
|
||||
let nsCSS = css as NSString
|
||||
var rewritten = css
|
||||
var normalized: [String] = []
|
||||
|
||||
for match in regex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)).reversed() {
|
||||
guard match.numberOfRanges > 1 else { continue }
|
||||
let rawValue = nsCSS.substring(with: match.range(at: 1))
|
||||
|
||||
guard let value = Double(rawValue), abs(value) > maxAbsolutePX else { continue }
|
||||
|
||||
let clamped = value < 0 ? -maxAbsolutePX : maxAbsolutePX
|
||||
if let range = Range(match.range, in: rewritten) {
|
||||
rewritten.replaceSubrange(range, with: "\(property): \(String(format: "%.0f", clamped))px;")
|
||||
normalized.append(property)
|
||||
}
|
||||
}
|
||||
return (rewritten, normalized)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBFontFallbackResolver {
|
||||
|
||||
static func fallbackChain(requestedFamily: String?, embeddedFamily: String?) -> RDEPUBFontFallbackChain {
|
||||
|
||||
let preferredCJK = ["PingFang SC", "Heiti SC", "Songti SC"]
|
||||
|
||||
let preferredLatin = ["Times New Roman", "Georgia", "Helvetica Neue"]
|
||||
return RDEPUBFontFallbackChain(
|
||||
requestedFamily: requestedFamily,
|
||||
embeddedFamily: embeddedFamily,
|
||||
systemFallbacks: preferredCJK + preferredLatin,
|
||||
finalFallback: UIFont.systemFont(ofSize: UIFont.systemFontSize).familyName
|
||||
)
|
||||
}
|
||||
|
||||
static func resolveFont(sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: baseFont)
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBStyleCompatibilityPolicy: Equatable {
|
||||
|
||||
public var allowPublisherFonts: Bool
|
||||
|
||||
public var allowPublisherMargins: Bool
|
||||
|
||||
public var normalizeLineHeight: Bool
|
||||
|
||||
public var normalizeTextIndent: Bool
|
||||
|
||||
public var clampImagesToViewport: Bool
|
||||
|
||||
public var fallbackUnsupportedWritingModes: Bool
|
||||
|
||||
public init(
|
||||
allowPublisherFonts: Bool = true,
|
||||
allowPublisherMargins: Bool = true,
|
||||
normalizeLineHeight: Bool = true,
|
||||
normalizeTextIndent: Bool = true,
|
||||
clampImagesToViewport: Bool = true,
|
||||
fallbackUnsupportedWritingModes: Bool = true
|
||||
) {
|
||||
self.allowPublisherFonts = allowPublisherFonts
|
||||
self.allowPublisherMargins = allowPublisherMargins
|
||||
self.normalizeLineHeight = normalizeLineHeight
|
||||
self.normalizeTextIndent = normalizeTextIndent
|
||||
self.clampImagesToViewport = clampImagesToViewport
|
||||
self.fallbackUnsupportedWritingModes = fallbackUnsupportedWritingModes
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBFontFallbackChain: Codable, Equatable {
|
||||
|
||||
public var requestedFamily: String?
|
||||
|
||||
public var embeddedFamily: String?
|
||||
|
||||
public var systemFallbacks: [String]
|
||||
|
||||
public var finalFallback: String
|
||||
|
||||
public init(
|
||||
requestedFamily: String? = nil,
|
||||
embeddedFamily: String? = nil,
|
||||
systemFallbacks: [String] = ["PingFang SC", "Heiti SC", "Times New Roman"],
|
||||
finalFallback: String = ".AppleSystemUIFont"
|
||||
) {
|
||||
self.requestedFamily = requestedFamily
|
||||
self.embeddedFamily = embeddedFamily
|
||||
self.systemFallbacks = systemFallbacks
|
||||
self.finalFallback = finalFallback
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBEmbeddedFontDescriptor: Codable, Equatable {
|
||||
|
||||
public var family: String?
|
||||
|
||||
public var href: String
|
||||
|
||||
public var format: String?
|
||||
|
||||
public var weight: Int?
|
||||
|
||||
public var style: String?
|
||||
}
|
||||
|
||||
public struct RDEPUBFontRegistrationResult: Codable, Equatable {
|
||||
|
||||
public var descriptor: RDEPUBEmbeddedFontDescriptor
|
||||
|
||||
public var fileURL: URL?
|
||||
|
||||
public var didRegister: Bool
|
||||
|
||||
public var errorDescription: String?
|
||||
}
|
||||
|
||||
public struct RDEPUBCSSCompatibilityReport: Codable, Equatable {
|
||||
|
||||
public var unsupportedRules: [String]
|
||||
|
||||
public var normalizedRules: [String]
|
||||
|
||||
public var fontFailures: [String]
|
||||
|
||||
public init(unsupportedRules: [String] = [], normalizedRules: [String] = [], fontFailures: [String] = []) {
|
||||
self.unsupportedRules = unsupportedRules
|
||||
self.normalizedRules = normalizedRules
|
||||
self.fontFailures = fontFailures
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBCSSCompatibilityResult {
|
||||
|
||||
var css: String
|
||||
|
||||
var report: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
+38
-11
@@ -4,14 +4,14 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 附件布局规范化器,负责缩放脚注、封面和通用图片的显示尺寸。
|
||||
struct RDEPUBAttachmentNormalizer {
|
||||
/// 脚注附件日志已输出标记(避免重复日志)
|
||||
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
|
||||
private static var didLogCoverAttachment = false
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
func normalize(
|
||||
_ attachment: DTTextAttachment,
|
||||
fontPointSize: CGFloat,
|
||||
@@ -25,23 +25,28 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
#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 {
|
||||
@@ -54,16 +59,20 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -76,9 +85,11 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -87,6 +98,7 @@ struct RDEPUBAttachmentNormalizer {
|
||||
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),
|
||||
@@ -95,26 +107,31 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -123,23 +140,31 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -147,16 +172,15 @@ struct RDEPUBAttachmentNormalizer {
|
||||
}
|
||||
#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
|
||||
@@ -165,15 +189,17 @@ struct RDEPUBAttachmentNormalizer {
|
||||
#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
|
||||
}
|
||||
@@ -182,6 +208,7 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
for value in attributes.values {
|
||||
let typeName = String(describing: type(of: value)).lowercased()
|
||||
|
||||
if typeName.contains("attachment") {
|
||||
return typeName.contains("image") ? .image : .generic
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBCFIMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"<([A-Za-z][A-Za-z0-9:_-]*)([^>]*\s(?:id|xml:id)\s*=\s*['"]([^'"]+)['"][^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
) else {
|
||||
return html
|
||||
}
|
||||
|
||||
let nsHTML = html as NSString
|
||||
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var rewritten = html
|
||||
for match in matches.reversed() {
|
||||
|
||||
guard match.numberOfRanges > 3,
|
||||
let fullRange = Range(match.range(at: 0), in: rewritten),
|
||||
let tagNameRange = Range(match.range(at: 1), in: html),
|
||||
let attributesRange = Range(match.range(at: 2), in: html),
|
||||
let fragmentRange = Range(match.range(at: 3), in: html) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let tagName = String(html[tagNameRange])
|
||||
let attributes = String(html[attributesRange])
|
||||
let fragmentID = String(html[fragmentRange])
|
||||
|
||||
guard attributes.range(of: "data-rd-cfi-marker", options: [.caseInsensitive]) == nil else {
|
||||
continue
|
||||
}
|
||||
|
||||
let marker = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: context.href,
|
||||
fileIndex: context.spineIndex ?? 0,
|
||||
chapterOffset: 0,
|
||||
fragmentID: fragmentID
|
||||
).rawValue
|
||||
|
||||
let replacement = "<\(tagName)\(attributes) data-rd-cfi-marker=\"\(Self.escapeAttribute(marker))\">"
|
||||
rewritten.replaceSubrange(fullRange, with: replacement)
|
||||
}
|
||||
|
||||
return rewritten
|
||||
}
|
||||
|
||||
private static func escapeAttribute(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import UIKit
|
||||
|
||||
import CoreText
|
||||
|
||||
/// 字体规范化器,负责注册 EPUB 嵌入字体并映射到用户设置字体。
|
||||
struct RDEPUBFontNormalizer {
|
||||
/// 已注册字体资源,避免重复调用 CTFontManager。
|
||||
|
||||
private static var registeredFontPaths = Set<String>()
|
||||
|
||||
@discardableResult
|
||||
func registerEmbeddedFonts(
|
||||
html: String,
|
||||
inlinedCSS: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
) {
|
||||
) -> [RDEPUBFontRegistrationResult] {
|
||||
|
||||
Self.registerEmbeddedFonts(
|
||||
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
|
||||
chapterHref: input.href,
|
||||
@@ -18,70 +20,119 @@ struct RDEPUBFontNormalizer {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 字体注册
|
||||
|
||||
/// 从 CSS 中解析 @font-face 规则,注册嵌入字体。
|
||||
@discardableResult
|
||||
static func registerEmbeddedFonts(
|
||||
in css: String,
|
||||
chapterHref: String,
|
||||
resourceResolver: RDEPUBResourceResolver?
|
||||
) {
|
||||
) -> [RDEPUBFontRegistrationResult] {
|
||||
|
||||
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
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [RDEPUBFontRegistrationResult] = []
|
||||
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
|
||||
|
||||
let family = declarationValue(named: "font-family", in: block)?
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
|
||||
let weight = declarationValue(named: "font-weight", in: block).flatMap(Int.init)
|
||||
|
||||
let style = declarationValue(named: "font-style", in: block)
|
||||
|
||||
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"))
|
||||
|
||||
let descriptor = RDEPUBEmbeddedFontDescriptor(
|
||||
family: family,
|
||||
href: rawReference,
|
||||
format: nil,
|
||||
weight: weight,
|
||||
style: style
|
||||
)
|
||||
|
||||
guard !rawReference.isEmpty,
|
||||
!rawReference.hasPrefix("data:"),
|
||||
!rawReference.hasPrefix("http://"),
|
||||
!rawReference.hasPrefix("https://"),
|
||||
!rawReference.hasPrefix("http:"),
|
||||
!rawReference.hasPrefix("https:"),
|
||||
|
||||
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
|
||||
|
||||
results.append(
|
||||
RDEPUBFontRegistrationResult(
|
||||
descriptor: descriptor,
|
||||
fileURL: nil,
|
||||
didRegister: false,
|
||||
errorDescription: "Font URL could not be resolved"
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
registerFontIfNeeded(at: fileURL)
|
||||
|
||||
let didRegister = registerFontIfNeeded(at: fileURL)
|
||||
results.append(
|
||||
RDEPUBFontRegistrationResult(
|
||||
descriptor: descriptor,
|
||||
fileURL: fileURL,
|
||||
didRegister: didRegister,
|
||||
errorDescription: didRegister ? nil : "Font was already registered or registration failed"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// 若字体尚未注册,则通过 CTFontManager 注册。
|
||||
/// - Parameter fileURL: 字体文件 URL
|
||||
static func registerFontIfNeeded(at fileURL: URL) {
|
||||
@discardableResult
|
||||
static func registerFontIfNeeded(at fileURL: URL) -> Bool {
|
||||
|
||||
let standardizedPath = fileURL.standardizedFileURL.path
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return }
|
||||
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
|
||||
guard !registeredFontPaths.contains(standardizedPath) else { return true }
|
||||
|
||||
let registered = CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
|
||||
if registered {
|
||||
|
||||
registeredFontPaths.insert(standardizedPath)
|
||||
}
|
||||
return registered
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -89,4 +140,21 @@ struct RDEPUBFontNormalizer {
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func declarationValue(named name: String, in block: String) -> String? {
|
||||
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b"# + NSRegularExpression.escapedPattern(for: name) + #"\s*:\s*([^;]+)"#
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let nsBlock = block as NSString
|
||||
|
||||
guard let match = regex.firstMatch(in: block, range: NSRange(location: 0, length: nsBlock.length)),
|
||||
match.numberOfRanges > 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nsBlock.substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -1,26 +1,16 @@
|
||||
/// RDEPUBFragmentMarkerInjector - Fragment 标记注入与偏移量提取
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Fragment 标记注入器,负责在 HTML 中注入 fragment 锚点标记并从渲染结果中提取偏移量。
|
||||
///
|
||||
/// 工作流程:
|
||||
/// 1. `process` 阶段:将 HTML 中的 `id` 属性元素转为 `${id=xxx}` 文本标记
|
||||
/// 2. `extractOffsets` 阶段:从 NSAttributedString 中扫描标记,记录偏移量后删除标记
|
||||
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
|
||||
/// 将 HTML 中的 id 元素注入 fragment 文本标记,供后续偏移量提取使用。
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectFragmentMarkers(into: html)
|
||||
}
|
||||
|
||||
/// 从渲染后的富文本中扫描 `${id=xxx}` 标记,提取 fragment ID 到字符偏移量的映射。
|
||||
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
|
||||
@@ -33,32 +23,43 @@ struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
/// HTML 规范化器,清理冗余字符并规范化附件 HTML 标记。
|
||||
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
/// 执行 HTML 规范化处理。
|
||||
/// - Parameters:
|
||||
/// - html: 原始 HTML
|
||||
/// - context: 排版上下文
|
||||
/// - Returns: 规范化后的 HTML
|
||||
|
||||
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")
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
@@ -37,9 +30,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
// MARK: - 附件 HTML 标记规范化
|
||||
|
||||
/// 处理 bodyPic div、脚注 img、封面 h1+img 等特殊 HTML 结构。
|
||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||
var normalized = html
|
||||
|
||||
@@ -51,10 +41,12 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
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
|
||||
@@ -75,6 +67,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let footnoteRegex = try? NSRegularExpression(
|
||||
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
||||
options: [.caseInsensitive]
|
||||
@@ -83,6 +76,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
using: footnoteRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
|
||||
mergeHTMLAttributes(
|
||||
into: tag,
|
||||
requiredClass: nil,
|
||||
@@ -96,6 +90,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let coverRegex = try? NSRegularExpression(
|
||||
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
|
||||
options: [.caseInsensitive]
|
||||
@@ -104,6 +99,7 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
using: coverRegex,
|
||||
in: normalized
|
||||
) { tag in
|
||||
|
||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
|
||||
let imageMatch = imageTagRegex.firstMatch(
|
||||
in: tag,
|
||||
@@ -133,15 +129,13 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
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 }
|
||||
|
||||
@@ -154,7 +148,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
return rewritten
|
||||
}
|
||||
|
||||
/// 合并 HTML 标签的 class 和 style 属性。
|
||||
static func mergeHTMLAttributes(
|
||||
into tag: String,
|
||||
requiredClass: String?,
|
||||
@@ -194,7 +187,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
return rewritten
|
||||
}
|
||||
|
||||
/// 注入 `<base>` 标签以解析相对路径。
|
||||
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
|
||||
guard let baseURL else {
|
||||
return html
|
||||
@@ -213,7 +205,6 @@ struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
|
||||
return "<head>\n\(baseTag)\n</head>\n" + html
|
||||
}
|
||||
|
||||
/// 工具方法:从字符串范围构建 CGSize 描述。
|
||||
static func string(from size: CGSize) -> String {
|
||||
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
|
||||
}
|
||||
|
||||
+30
-17
@@ -1,17 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// 渲染诊断收集器,扫描 HTML 中的资源引用并生成诊断信息。
|
||||
struct RDEPUBRenderDiagnosticsCollector {
|
||||
/// 外部样式表链接正则
|
||||
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 图片源地址正则
|
||||
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
|
||||
/// 收集 HTML 中图片资源的引用诊断。
|
||||
/// - Parameters:
|
||||
/// - html: 待扫描的 HTML
|
||||
/// - input: 排版输入上下文
|
||||
/// - Returns: 资源引用诊断列表
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func collect(
|
||||
in html: String,
|
||||
input: RDEPUBTypesettingInput
|
||||
@@ -24,9 +23,9 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 图片诊断
|
||||
|
||||
|
||||
/// 扫描 HTML 中的 <img> 标签,构建资源引用诊断。
|
||||
|
||||
static func collectImageDiagnostics(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
@@ -50,9 +49,9 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 外部样式表内联
|
||||
|
||||
|
||||
/// 查找 `<link rel=stylesheet>` 标签,内联 CSS 内容。
|
||||
|
||||
static func inlineLinkedStyleSheets(
|
||||
in html: String,
|
||||
chapterHref: String,
|
||||
@@ -69,10 +68,14 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
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))
|
||||
@@ -103,9 +106,9 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
|
||||
}
|
||||
|
||||
// MARK: - CSS 资源 URL 重写
|
||||
|
||||
|
||||
/// 重写 CSS 中的相对 url() 引用,解析为绝对文件路径。
|
||||
|
||||
static func rewriteCSSResourceURLs(
|
||||
in css: String,
|
||||
styleSheetFileURL: URL
|
||||
@@ -121,18 +124,25 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
}
|
||||
|
||||
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("#") {
|
||||
|
||||
if rawValue.hasPrefix("data:")
|
||||
|| rawValue.hasPrefix("http:")
|
||||
|| rawValue.hasPrefix("https:") {
|
||||
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)
|
||||
@@ -141,8 +151,6 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
return rewrittenCSS
|
||||
}
|
||||
|
||||
// MARK: - 资源引用解析
|
||||
|
||||
static func resolveReference(
|
||||
_ reference: String,
|
||||
kind: RDEPUBTextResourceReferenceKind,
|
||||
@@ -150,11 +158,16 @@ struct RDEPUBRenderDiagnosticsCollector {
|
||||
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,
|
||||
|
||||
+18
-23
@@ -1,29 +1,19 @@
|
||||
/// RDEPUBSemanticMarkerInjector - 分页语义标记注入与应用
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 语义标记注入器,为 HTML 标签注入分页语义信息(块级类型、分页提示、附件放置方式等)。
|
||||
///
|
||||
/// 分两阶段工作:
|
||||
/// 1. HTML 阶段(`process`):解析标签并注入 `${rd-sem-start/end}` 语义标记
|
||||
/// 2. 渲染后阶段(`apply`):将标记解析为 NSAttributedString 属性
|
||||
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||||
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
|
||||
/// 为 HTML 标签注入 `${rd-sem-start/end}` 语义标记,标记块级元素类型和分页提示。
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
|
||||
Self.injectPaginationSemanticMarkers(into: html)
|
||||
}
|
||||
|
||||
/// 解析渲染后富文本中的语义标记,将其转为 NSAttributedString 属性并删除标记文本。
|
||||
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
|
||||
@@ -36,8 +26,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
}
|
||||
|
||||
var output = ""
|
||||
|
||||
var cursor = 0
|
||||
|
||||
var openTagStack: [(name: String, id: String)] = []
|
||||
|
||||
var nextMarkerID = 0
|
||||
|
||||
for match in matches {
|
||||
@@ -47,9 +40,11 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
|
||||
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)
|
||||
@@ -57,16 +52,20 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -77,30 +76,34 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
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)
|
||||
@@ -114,8 +117,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 语义推断
|
||||
|
||||
static func htmlTagName(from loweredTag: String) -> String? {
|
||||
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("<") else { return nil }
|
||||
@@ -297,7 +298,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
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) {
|
||||
@@ -306,7 +306,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 推断语义提示列表。
|
||||
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
|
||||
if let rawValue = attributes[.rdPageSemanticHints] as? String {
|
||||
let hints = rawValue
|
||||
@@ -317,7 +316,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 推断附件放置方式。
|
||||
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
|
||||
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
|
||||
@@ -329,9 +327,6 @@ struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 内部类型
|
||||
|
||||
/// 单个 HTML 元素的分页语义信息,由标签名和属性推断而来。
|
||||
struct RDPaginationSemantics {
|
||||
var id: String
|
||||
var blockKind: RDEPUBTextBlockKind?
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
/// 样式表组合结果,包含合成后的 HTML、CSS 层列表及诊断信息。
|
||||
struct RDEPUBStyleSheetComposition {
|
||||
/// 合成后的 HTML 文本
|
||||
|
||||
var html: String
|
||||
/// 五层 CSS 样式层(default/replace/dark/epub/user)
|
||||
|
||||
var layers: [RDEPUBTextStyleSheetLayer]
|
||||
/// 内联后的 CSS 内容
|
||||
|
||||
var inlinedCSS: String
|
||||
/// 资源引用诊断列表
|
||||
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
var compatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
|
||||
/// EPUB 样式表合成器,负责组装五层 CSS 并注入 HTML。
|
||||
struct RDEPUBStyleSheetComposer {
|
||||
/// 将 CSS 层注入 HTML,返回合成结果。
|
||||
/// - Parameters:
|
||||
/// - html: 原始 HTML
|
||||
/// - input: 排版输入上下文
|
||||
/// - Returns: 包含合成 HTML、CSS 层和诊断信息的结果
|
||||
|
||||
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
|
||||
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
|
||||
in: html,
|
||||
@@ -26,9 +22,10 @@ struct RDEPUBStyleSheetComposer {
|
||||
baseURL: input.baseURL,
|
||||
resourceResolver: input.resourceResolver
|
||||
)
|
||||
let compatibility = RDEPUBCSSCompatibilityLayer().sanitize(stylesheetHrefReplacements.inlinedCSS)
|
||||
let layers = Self.makeStyleSheetLayers(
|
||||
style: input.style,
|
||||
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
epubCSS: compatibility.css,
|
||||
contentLanguageCode: input.contentLanguageCode,
|
||||
sourceHTML: input.rawHTML
|
||||
)
|
||||
@@ -61,14 +58,12 @@ struct RDEPUBStyleSheetComposer {
|
||||
return RDEPUBStyleSheetComposition(
|
||||
html: composedHTML,
|
||||
layers: layers,
|
||||
inlinedCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||
diagnostics: stylesheetHrefReplacements.diagnostics
|
||||
inlinedCSS: compatibility.css,
|
||||
diagnostics: stylesheetHrefReplacements.diagnostics,
|
||||
compatibilityReport: compatibility.report
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CSS 层组装
|
||||
|
||||
/// 构建五层 CSS 数组(default/replace/dark/epub/user)。
|
||||
static func makeStyleSheetLayers(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
epubCSS: String,
|
||||
@@ -93,17 +88,13 @@ struct RDEPUBStyleSheetComposer {
|
||||
return layers
|
||||
}
|
||||
|
||||
// MARK: - Style 注入
|
||||
|
||||
/// `<style>` 标签的注入位置。
|
||||
enum StyleInjectionPosition {
|
||||
/// 注入到 `<head>` 标签之后
|
||||
|
||||
case headStart
|
||||
/// 注入到 `</head>` 标签之前
|
||||
|
||||
case headEnd
|
||||
}
|
||||
|
||||
/// 向 HTML 注入 <style> 标签。
|
||||
static func injectStyleTag(
|
||||
into html: String,
|
||||
styleID: String,
|
||||
@@ -133,8 +124,6 @@ struct RDEPUBStyleSheetComposer {
|
||||
return styleTag + "\n" + html
|
||||
}
|
||||
|
||||
// MARK: - 各层 CSS
|
||||
|
||||
private static func defaultCSS() -> String {
|
||||
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||||
}
|
||||
@@ -186,8 +175,6 @@ struct RDEPUBStyleSheetComposer {
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
// MARK: - 语言检测
|
||||
|
||||
private static func prefersLatinLanguageCSS(
|
||||
languageCode: String?,
|
||||
sourceHTML: String
|
||||
|
||||
@@ -5,25 +5,8 @@ import CoreText
|
||||
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,
|
||||
@@ -35,27 +18,33 @@ enum RDEPUBTextRendererSupport {
|
||||
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",
|
||||
@@ -65,19 +54,23 @@ enum RDEPUBTextRendererSupport {
|
||||
.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,
|
||||
@@ -101,59 +94,70 @@ enum RDEPUBTextRendererSupport {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用)
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
static func normalizeReadingAttributes(
|
||||
in attributedString: NSMutableAttributedString,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
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)
|
||||
|
||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||
|
||||
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),
|
||||
@@ -162,7 +166,6 @@ enum RDEPUBTextRendererSupport {
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
/// 构建共享段落样式。
|
||||
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
|
||||
@@ -1,69 +1,77 @@
|
||||
import UIKit
|
||||
|
||||
/// 排版输入参数,包含章节 HTML、样式和布局配置。
|
||||
struct RDEPUBTypesettingInput {
|
||||
/// 章节相对路径
|
||||
|
||||
var href: String
|
||||
/// 章节标题
|
||||
|
||||
var spineIndex: Int?
|
||||
|
||||
var title: String
|
||||
/// 原始 HTML 内容
|
||||
|
||||
var rawHTML: String
|
||||
/// HTML 基础 URL,用于解析相对路径
|
||||
|
||||
var baseURL: URL?
|
||||
/// 渲染样式
|
||||
|
||||
var style: RDEPUBTextRenderStyle
|
||||
/// 资源解析器
|
||||
|
||||
var resourceResolver: RDEPUBResourceResolver?
|
||||
/// 内容语言代码(如 zh-CN)
|
||||
|
||||
var contentLanguageCode: String?
|
||||
/// 页面尺寸
|
||||
|
||||
var pageSize: CGSize?
|
||||
/// 页面布局配置
|
||||
|
||||
var layoutConfig: RDEPUBTextLayoutConfig?
|
||||
}
|
||||
|
||||
/// 排版输出结果,包含渲染请求和诊断信息。
|
||||
struct RDEPUBTypesettingOutput {
|
||||
/// 章节渲染请求
|
||||
|
||||
var request: RDEPUBTextChapterRenderRequest
|
||||
/// 资源引用诊断列表
|
||||
|
||||
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
|
||||
}
|
||||
|
||||
/// 排版阶段协议,定义 HTML 处理管线中的单个步骤。
|
||||
protocol RDEPUBTypesettingStage {
|
||||
/// 处理 HTML 并返回转换后的结果。
|
||||
/// - Parameters:
|
||||
/// - html: 输入 HTML
|
||||
/// - context: 排版上下文
|
||||
/// - Returns: 处理后的 HTML
|
||||
|
||||
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
|
||||
}
|
||||
|
||||
/// 排版管线,依次执行 HTML 规范化、语义标记、样式合成、字体注册和诊断收集。
|
||||
struct RDEPUBTextTypesetterPipeline {
|
||||
/// 从排版输入构建渲染请求。
|
||||
/// - Parameter input: 排版输入参数
|
||||
/// - Returns: 包含渲染请求和诊断信息的输出
|
||||
|
||||
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
|
||||
|
||||
let htmlNormalizer = RDEPUBHTMLNormalizer()
|
||||
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
|
||||
let cfiMarkerInjector = RDEPUBCFIMarkerInjector()
|
||||
let styleSheetComposer = RDEPUBStyleSheetComposer()
|
||||
let fontNormalizer = RDEPUBFontNormalizer()
|
||||
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
|
||||
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
|
||||
|
||||
let normalizedHTML = semanticMarkerInjector.process(
|
||||
htmlNormalizer.process(input.rawHTML, context: input),
|
||||
let normalizedHTML = cfiMarkerInjector.process(
|
||||
semanticMarkerInjector.process(
|
||||
htmlNormalizer.process(input.rawHTML, context: input),
|
||||
context: input
|
||||
),
|
||||
context: input
|
||||
)
|
||||
|
||||
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
|
||||
fontNormalizer.registerEmbeddedFonts(
|
||||
|
||||
let fontResults = fontNormalizer.registerEmbeddedFonts(
|
||||
html: normalizedHTML,
|
||||
inlinedCSS: styleSheetComposition.inlinedCSS,
|
||||
input: input
|
||||
)
|
||||
|
||||
let compatibilityReport = Self.mergedCompatibilityReport(
|
||||
styleSheetComposition.compatibilityReport,
|
||||
fontResults: fontResults
|
||||
)
|
||||
|
||||
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
|
||||
|
||||
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
|
||||
|
||||
let context = RDEPUBTextChapterContext(
|
||||
@@ -72,7 +80,8 @@ struct RDEPUBTextTypesetterPipeline {
|
||||
html: markedHTML,
|
||||
baseURL: input.baseURL,
|
||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
|
||||
resourceDiagnostics: diagnostics
|
||||
resourceDiagnostics: diagnostics,
|
||||
styleCompatibilityReport: compatibilityReport
|
||||
)
|
||||
let request = RDEPUBTextChapterRenderRequest(
|
||||
context: context,
|
||||
@@ -82,7 +91,27 @@ struct RDEPUBTextTypesetterPipeline {
|
||||
)
|
||||
return RDEPUBTypesettingOutput(
|
||||
request: request,
|
||||
diagnostics: diagnostics
|
||||
diagnostics: diagnostics,
|
||||
styleCompatibilityReport: compatibilityReport
|
||||
)
|
||||
}
|
||||
|
||||
private static func mergedCompatibilityReport(
|
||||
_ report: RDEPUBCSSCompatibilityReport,
|
||||
fontResults: [RDEPUBFontRegistrationResult]
|
||||
) -> RDEPUBCSSCompatibilityReport {
|
||||
|
||||
let fontFailures = fontResults.compactMap { result -> String? in
|
||||
guard result.didRegister == false else { return nil }
|
||||
let family = result.descriptor.family ?? "unknown-family"
|
||||
let href = result.descriptor.href
|
||||
let reason = result.errorDescription ?? "Font registration failed"
|
||||
return "\(family) <\(href)>: \(reason)"
|
||||
}
|
||||
return RDEPUBCSSCompatibilityReport(
|
||||
unsupportedRules: report.unsupportedRules,
|
||||
normalizedRules: report.normalizedRules,
|
||||
fontFailures: report.fontFailures + fontFailures
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBNotePopupCoordinator {
|
||||
|
||||
static func present(
|
||||
_ note: RDEPUBResolvedNote,
|
||||
from controller: UIViewController,
|
||||
onReturnToSource: (() -> Void)? = nil,
|
||||
onOpenNoteLocation: (() -> Void)? = nil
|
||||
) {
|
||||
let popup = RDEPUBNotePopupViewController(
|
||||
note: note,
|
||||
onReturnToSource: onReturnToSource,
|
||||
onOpenNoteLocation: onOpenNoteLocation
|
||||
)
|
||||
let navigationController = UINavigationController(rootViewController: popup)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
if let sheet = navigationController.sheetPresentationController {
|
||||
sheet.detents = [.medium(), .large()]
|
||||
sheet.prefersGrabberVisible = true
|
||||
}
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBNotePopupViewController: UIViewController {
|
||||
|
||||
private let note: RDEPUBResolvedNote
|
||||
private let onReturnToSource: (() -> Void)?
|
||||
private let onOpenNoteLocation: (() -> Void)?
|
||||
|
||||
private let textView = UITextView()
|
||||
private let actionsStackView = UIStackView()
|
||||
|
||||
init(
|
||||
note: RDEPUBResolvedNote,
|
||||
onReturnToSource: (() -> Void)? = nil,
|
||||
onOpenNoteLocation: (() -> Void)? = nil
|
||||
) {
|
||||
self.note = note
|
||||
self.onReturnToSource = onReturnToSource
|
||||
self.onOpenNoteLocation = onOpenNoteLocation
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = note.title ?? "注释"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .done,
|
||||
target: self,
|
||||
action: #selector(close)
|
||||
)
|
||||
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textView.isEditable = false
|
||||
textView.alwaysBounceVertical = true
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
|
||||
textView.attributedText = attributedContent()
|
||||
textView.accessibilityIdentifier = "epub.reader.note.text"
|
||||
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionsStackView.axis = .horizontal
|
||||
actionsStackView.spacing = 12
|
||||
actionsStackView.distribution = .fillEqually
|
||||
actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
|
||||
configureActionButtons()
|
||||
|
||||
view.addSubview(actionsStackView)
|
||||
view.addSubview(textView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
|
||||
actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
|
||||
actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||
actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func configureActionButtons() {
|
||||
if note.sourceLocation != nil, onReturnToSource != nil {
|
||||
actionsStackView.addArrangedSubview(
|
||||
makeActionButton(title: "返回原文", action: #selector(returnToSource))
|
||||
)
|
||||
}
|
||||
if onOpenNoteLocation != nil {
|
||||
actionsStackView.addArrangedSubview(
|
||||
makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
|
||||
)
|
||||
}
|
||||
actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
|
||||
}
|
||||
|
||||
private func makeActionButton(title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.cornerStyle = .medium
|
||||
configuration.title = title
|
||||
configuration.baseBackgroundColor = .secondarySystemBackground
|
||||
configuration.baseForegroundColor = .label
|
||||
button.configuration = configuration
|
||||
if title == "返回原文" {
|
||||
button.accessibilityIdentifier = "epub.reader.note.return"
|
||||
} else if title == "打开注释原文" {
|
||||
button.accessibilityIdentifier = "epub.reader.note.open"
|
||||
}
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func attributedContent() -> NSAttributedString {
|
||||
let html = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font: -apple-system-body;
|
||||
color: #1f1f1f;
|
||||
line-height: 1.55;
|
||||
}
|
||||
a { color: #3b6ea8; }
|
||||
img, svg, table { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>\(note.html)</body>
|
||||
</html>
|
||||
"""
|
||||
guard let data = html.data(using: .utf8),
|
||||
let attributed = try? NSMutableAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
) else {
|
||||
return NSAttributedString(string: note.plainText)
|
||||
}
|
||||
return attributed
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func returnToSource() {
|
||||
dismiss(animated: true) { [onReturnToSource] in
|
||||
onReturnToSource?()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func openNoteLocation() {
|
||||
dismiss(animated: true) { [onOpenNoteLocation] in
|
||||
onOpenNoteLocation?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 底部工具栏
|
||||
|
||||
/// 阅读器底部工具栏
|
||||
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
|
||||
/// 按钮可见性可根据 configuration 动态调整
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 点击目录按钮回调
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
/// 点击书签列表按钮回调
|
||||
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
/// 点击高亮列表按钮回调
|
||||
|
||||
var onShowHighlights: (() -> Void)?
|
||||
/// 点击新建标注按钮回调
|
||||
|
||||
var onAddHighlight: (() -> Void)?
|
||||
/// 点击设置按钮回调
|
||||
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
/// 水平等分布局的按钮容器
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
@@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
return view
|
||||
}()
|
||||
|
||||
/// 目录按钮
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 书签列表按钮
|
||||
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 高亮列表按钮
|
||||
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 新建标注按钮
|
||||
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 设置按钮
|
||||
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.bottomToolbar"
|
||||
|
||||
|
||||
addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(chapterButton)
|
||||
@@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 按钮状态控制
|
||||
|
||||
/// 设置新建标注按钮是否可用(有选中文本时可用)
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 目录面板
|
||||
|
||||
/// EPUB 阅读器的目录面板控制器
|
||||
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
/// 用户选择目录项时的回调
|
||||
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
|
||||
private let currentItem: RDEPUBReaderTableOfContentsItem?
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
init(
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
// RDEPUBReaderController+ContentDelegates.swift
|
||||
// EPUB 阅读器内容视图代理实现
|
||||
// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径
|
||||
// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 内容代理扩展
|
||||
///
|
||||
/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的
|
||||
/// 位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||||
|
||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
/// Web 内容视图位置更新回调,同步阅读上下文与持久化位置
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
guard readerView.currentPage >= 0,
|
||||
activePages.indices.contains(readerView.currentPage) else {
|
||||
@@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// Web 内容视图选区变化回调,转换为统一选区模型
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
if let selection {
|
||||
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
||||
@@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web 内容视图选区菜单操作回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
handleSelectionMenuAction(action, selection: currentSelection)
|
||||
}
|
||||
|
||||
/// Web 内容视图内部链接点击回调,执行页内跳转
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
guard let readingSession,
|
||||
let pageNumber = readingSession.queueNavigation(
|
||||
to: location,
|
||||
@@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||
}
|
||||
|
||||
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||
openExternalURLIfAllowed(url)
|
||||
}
|
||||
|
||||
/// Web 内容视图 JavaScript 错误日志回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||
#if DEBUG
|
||||
print("EPUB JS Error: \(message)")
|
||||
@@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
/// 文本内容视图选区变化回调,标准化后更新当前选区
|
||||
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||||
guard let selection else {
|
||||
updateCurrentSelection(nil)
|
||||
@@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
updateCurrentSelection(selection)
|
||||
}
|
||||
|
||||
/// 文本内容视图选区菜单操作回调
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
|
||||
readerView.handleContentTap(at: point, in: contentView)
|
||||
}
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
@@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
|
||||
/// 根据 EPUB 位置计算对应的页码
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let publication,
|
||||
let bookPageMap = readerContext.bookPageMap,
|
||||
@@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页码解析对应的文本位置
|
||||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
|
||||
let startOffset = resolvedPage.page.pageStartOffset
|
||||
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
||||
let fragmentID = nearestFragmentID(
|
||||
beforeOrAt: startOffset,
|
||||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: resolvedPage.page.href,
|
||||
progression: Double(startOffset) / Double(chapterLength),
|
||||
lastProgression: Double(endOffset) / Double(chapterLength),
|
||||
fragment: fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(
|
||||
start: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: startOffset,
|
||||
fragmentID: fragmentID
|
||||
),
|
||||
end: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: endOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
)
|
||||
let chapterData = makeRuntimeChapterData(from: resolvedPage)
|
||||
let location = chapterData.location(
|
||||
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
if let publication {
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
@@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
return location
|
||||
}
|
||||
|
||||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
readingSession?.updateReadingContext(
|
||||
@@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从文本书籍生成原生文本快照(页面列表 + 章节信息)
|
||||
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
|
||||
let chapters = textBook.chapterInfos
|
||||
let pages = textBook.pages.map {
|
||||
@@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
|
||||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||||
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
|
||||
return offset
|
||||
}
|
||||
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
|
||||
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
|
||||
return cfiOffset
|
||||
}
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
@@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
return bestID
|
||||
}
|
||||
|
||||
// MARK: - 外部链接策略
|
||||
|
||||
private func shouldAllowExternalURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||||
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
|
||||
@@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
||||
guard let publication else { return false }
|
||||
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
|
||||
let sourceLocation = currentVisibleLocation()
|
||||
let sourceCFI = sourceLocation?.cfi
|
||||
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
|
||||
guard let note = resolver.resolveInternalLink(
|
||||
sourceHref: sourceHref,
|
||||
sourceCFI: sourceCFI,
|
||||
targetLocation: location,
|
||||
sourceLocation: sourceLocation,
|
||||
relativeToSpineIndex: fromSpineIndex
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
RDEPUBNotePopupCoordinator.present(
|
||||
note,
|
||||
from: self,
|
||||
onReturnToSource: { [weak self] in
|
||||
guard let self, let sourceLocation = note.sourceLocation else { return }
|
||||
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
|
||||
},
|
||||
onOpenNoteLocation: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
|
||||
}
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func navigateToLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int?,
|
||||
animated: Bool
|
||||
) {
|
||||
guard let pageNumber = readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) else {
|
||||
return
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
|
||||
}
|
||||
|
||||
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
|
||||
hideAttachmentTooltipIfNeeded()
|
||||
|
||||
@@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDEPUBReaderController {
|
||||
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: resolvedPage.chapterIndex,
|
||||
spineIndex: resolvedPage.chapter.spineIndex,
|
||||
href: resolvedPage.chapter.href,
|
||||
title: resolvedPage.chapter.title,
|
||||
attributedContent: resolvedPage.chapter.typesetAttributedString,
|
||||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
|
||||
pages: resolvedPage.chapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
|
||||
|
||||
var onBackgroundTap: (() -> Void)?
|
||||
|
||||
var tooltipView: RDEPUBAttachmentTooltipView? {
|
||||
@@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipView: UIView {
|
||||
|
||||
enum ArrowPlacement {
|
||||
|
||||
case top
|
||||
|
||||
case bottom
|
||||
}
|
||||
|
||||
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
|
||||
|
||||
private let arrowSize = CGSize(width: 20, height: 10)
|
||||
|
||||
private let cornerRadius: CGFloat = 18
|
||||
|
||||
private(set) var minimumArrowX: CGFloat = 28
|
||||
|
||||
private var arrowTipX: CGFloat?
|
||||
|
||||
private var arrowPlacement: ArrowPlacement = .bottom
|
||||
|
||||
private let textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
@@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView {
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
return label
|
||||
}()
|
||||
|
||||
private let shapeLayer = CAShapeLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
// RDEPUBReaderController+DataSource.swift
|
||||
// EPUB 阅读器数据源与代理实现
|
||||
// 实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 数据源与代理扩展
|
||||
///
|
||||
/// 本文件实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
/// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
/// 返回阅读器总页数
|
||||
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: resolvedPage.page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page)
|
||||
)
|
||||
@@ -40,11 +39,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: textBook.pages.count,
|
||||
configuration: configuration,
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page)
|
||||
)
|
||||
@@ -68,14 +78,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
return contentView
|
||||
}
|
||||
|
||||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
(textBook == nil && readerContext.bookPageMap == nil)
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
/// 获取指定文本页面上的高亮标注列表
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
@@ -91,9 +99,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前文本页需要展示的搜索状态。
|
||||
/// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引,
|
||||
/// 这样渲染层不需要再关心 href 规范化差异。
|
||||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||
guard let globalSearchState = searchState else { return nil }
|
||||
|
||||
@@ -237,6 +242,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
@@ -274,31 +280,25 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
return publication.resourceResolver.normalizedHref(href) ?? href
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据
|
||||
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) {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
// 如果正在重新分页,跳过后续操作,避免状态冲突
|
||||
guard !isRepaginating else { return }
|
||||
|
||||
// 用户开始导航时,应用后台解析完成的完整 map
|
||||
let previousCurrentPage = readerView.currentPage
|
||||
runtime.applyPendingFullPageMapIfNeeded()
|
||||
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
|
||||
@@ -320,7 +320,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
|
||||
persist(location: location)
|
||||
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
|
||||
// 记录翻页到 JumpSession
|
||||
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
return
|
||||
}
|
||||
@@ -332,17 +332,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
// 记录翻页到 JumpSession
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
}
|
||||
|
||||
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
||||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||||
_ = isLandscape
|
||||
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
|
||||
}
|
||||
|
||||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil || readerContext.bookPageMap != nil,
|
||||
!isRepaginating,
|
||||
@@ -365,12 +362,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
|
||||
isReconcilingTextPaginationSize = true
|
||||
// 使用防抖延迟,避免连续快速点击字号时重复触发
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isReconcilingTextPaginationSize = false
|
||||
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
|
||||
// 再次检查尺寸是否仍然变化,避免过期的重新分页
|
||||
|
||||
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
|
||||
guard currentSize.width > 0, currentSize.height > 0 else { return }
|
||||
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
// RDEPUBReaderController+PublicAPI.swift
|
||||
// RDEPUBReaderController 的公开 API 扩展,提供书签、高亮、目录跳转、搜索等操作
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 匹配的书签对象,不存在时返回 nil
|
||||
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
runtime.bookmark(withID: id)
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 匹配的高亮对象,不存在时返回 nil
|
||||
public func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
runtime.highlight(withID: id)
|
||||
}
|
||||
|
||||
/// 获取当前页的原生文本语义摘要,用于调试和可访问性
|
||||
/// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
let resolvedPage: RDEPUBTextPage?
|
||||
if let textBook {
|
||||
@@ -79,12 +59,6 @@ extension RDEPUBReaderController {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 添加高亮标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - color: 高亮颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -94,13 +68,6 @@ extension RDEPUBReaderController {
|
||||
runtime.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 添加指定样式的标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - style: 标注样式(下划线、高亮等)
|
||||
/// - color: 标注颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -111,72 +78,40 @@ extension RDEPUBReaderController {
|
||||
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 插入或更新高亮标注(存在则更新,不存在则插入)
|
||||
/// - Parameter highlight: 要写入的高亮对象
|
||||
/// - Returns: 写入后的高亮对象
|
||||
@discardableResult
|
||||
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
runtime.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
/// 移除指定高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 被移除的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
runtime.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
/// 更新高亮标注的批注内容
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - note: 新的批注文字,传 nil 清除批注
|
||||
/// - Returns: 更新后的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
runtime.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮标注所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 移除当前书籍的所有高亮标注
|
||||
public func removeAllHighlights() {
|
||||
runtime.removeAllHighlights()
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: EPUB 原生目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: RDReader 目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 通过目录 href 跳转到对应阅读位置,支持带锚点的 href
|
||||
/// - Parameters:
|
||||
/// - href: 目录资源路径(可含 #fragment)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
|
||||
guard publication != nil else { return false }
|
||||
@@ -193,61 +128,40 @@ extension RDEPUBReaderController {
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签
|
||||
/// - Parameter note: 可选批注文字
|
||||
/// - Returns: 创建的书签对象,位置已存在书签时返回 nil
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态(有则移除,无则添加)
|
||||
/// - Parameter note: 添加时可附带的批注文字
|
||||
/// - Returns: 添加时返回新书签,移除时返回被移除的书签
|
||||
@discardableResult
|
||||
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 移除指定书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 被移除的书签对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
runtime.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 书签 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@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)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否存在下一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchNext() -> Bool {
|
||||
runtime.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否存在上一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchPrevious() -> Bool {
|
||||
runtime.searchPrevious()
|
||||
}
|
||||
|
||||
/// 清除搜索状态和高亮
|
||||
public func clearSearch() {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
|
||||
@@ -1,47 +1,32 @@
|
||||
// RDEPUBReaderController+RenderSupport.swift
|
||||
// EPUB 阅读器渲染支持辅助方法
|
||||
// 提供获取当前布局上下文、偏好设置、页面尺寸、渲染样式、文本布局配置,
|
||||
// 以及构建渲染请求、管理分页宿主视图和回退位置查询等功能。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 渲染支持扩展
|
||||
///
|
||||
/// 本文件提供阅读器渲染所需的辅助方法,包括获取当前布局上下文、偏好设置、页面尺寸、
|
||||
/// 渲染样式、文本布局配置,以及构建渲染请求和回退位置查询等功能。
|
||||
|
||||
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)
|
||||
@@ -53,7 +38,6 @@ extension RDEPUBReaderController {
|
||||
return paginationHostView
|
||||
}
|
||||
|
||||
/// 为指定页面索引构建渲染请求(含位置、高亮、搜索状态)
|
||||
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
|
||||
guard let publication, activePages.indices.contains(pageIndex) else {
|
||||
return nil
|
||||
@@ -75,13 +59,11 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定页面索引的回退位置(当无法确定精确位置时使用)
|
||||
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 {
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
// RDEPUBReaderController+RuntimeBridge.swift
|
||||
// EPUB 阅读器运行时桥接层
|
||||
// 将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 运行时桥接扩展
|
||||
///
|
||||
/// 本文件将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
/// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 应用阅读器视图配置,处理显示类型/翻页方向/横屏双页等变更并恢复位置
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
@@ -32,17 +23,14 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动初始加载流程(如果尚未加载)
|
||||
func startInitialLoadIfNeeded() {
|
||||
runtime.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 加载 EPUB 出版物
|
||||
func loadPublication() {
|
||||
runtime.loadPublication()
|
||||
}
|
||||
|
||||
/// 应用已解析的出版物数据(解析器、出版物模型、书签、高亮等)
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -61,17 +49,14 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对出版物执行分页计算
|
||||
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?
|
||||
@@ -79,17 +64,14 @@ extension RDEPUBReaderController {
|
||||
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 保留当前位置重新执行分页
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
runtime.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -103,27 +85,22 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的位置
|
||||
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)
|
||||
}
|
||||
|
||||
/// 将选区限定到指定 spine 范围内
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@@ -131,60 +108,49 @@ extension RDEPUBReaderController {
|
||||
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保留当前位置
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
runtime.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部文本书籍
|
||||
func rebuildExternalTextBook() {
|
||||
runtime.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 更新阅读器 UI 装饰层(导航栏、工具栏等)
|
||||
func updateReaderChrome() {
|
||||
runtime.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 展示书签管理界面
|
||||
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,
|
||||
@@ -193,7 +159,6 @@ extension RDEPUBReaderController {
|
||||
persistence?.saveReaderSettings(settings)
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要重新分页
|
||||
func requiresRepagination(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@@ -210,7 +175,6 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要刷新可见内容
|
||||
func requiresVisibleRefresh(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@@ -220,17 +184,14 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
|
||||
}
|
||||
|
||||
/// 展示目录界面
|
||||
func presentTableOfContents() {
|
||||
runtime.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
runtime.handleBackAction()
|
||||
}
|
||||
|
||||
/// 处理错误:停止分页、隐藏加载指示器、显示错误信息并通知代理
|
||||
func handle(error: Error) {
|
||||
isRepaginating = false
|
||||
hideLoading()
|
||||
@@ -243,23 +204,19 @@ extension RDEPUBReaderController {
|
||||
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
|
||||
@@ -267,21 +224,17 @@ extension RDEPUBReaderController {
|
||||
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: - 手势识别器代理
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
/// 手势识别器代理,始终返回 true 允许手势触发
|
||||
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
// RDEPUBReaderController+TableOfContents.swift
|
||||
// EPUB 阅读器目录功能
|
||||
// 提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// RDEPUBReaderController 目录扩展
|
||||
///
|
||||
/// 本文件提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
/// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 解析当前阅读位置对应的目录项,按页码或 href 匹配
|
||||
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContentsItems(
|
||||
from: publication?.tableOfContents ?? [],
|
||||
@@ -51,7 +42,6 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0,
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文件说明
|
||||
|
||||
/// EPUB 阅读器主控制器
|
||||
/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验
|
||||
/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面
|
||||
///
|
||||
/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation
|
||||
///
|
||||
/// **核心职责:**
|
||||
/// - EPUB 解析与分页(支持流式排版和固定布局)
|
||||
/// - 阅读位置的持久化与恢复
|
||||
/// - 高亮、书签的增删改查
|
||||
/// - 工具栏、目录、设置等 UI 面板的协调管理
|
||||
/// - 搜索功能(全文搜索、前后跳转)
|
||||
///
|
||||
/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation
|
||||
|
||||
// MARK: - 阅读器主控制器
|
||||
|
||||
public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
// MARK: - 公开属性
|
||||
|
||||
/// 阅读器委托,用于接收阅读状态变更通知
|
||||
public weak var delegate: RDEPUBReaderDelegate?
|
||||
|
||||
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
readerContext.configuration = configuration
|
||||
@@ -54,65 +31,58 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前阅读位置
|
||||
public var currentLocation: RDEPUBLocation? {
|
||||
currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 当前页码(从 1 开始),nil 表示尚未加载
|
||||
public var currentPageNumber: Int? {
|
||||
guard readerView.currentPage >= 0 else { return nil }
|
||||
return readerView.currentPage + 1
|
||||
}
|
||||
|
||||
/// 当前用户选中的文本(只读),用于创建高亮/批注
|
||||
public internal(set) var currentSelection: RDEPUBSelection? {
|
||||
get { readerContext.currentSelection }
|
||||
set { readerContext.currentSelection = newValue }
|
||||
}
|
||||
|
||||
/// 所有高亮标注
|
||||
public var highlights: [RDEPUBHighlight] {
|
||||
activeHighlights
|
||||
}
|
||||
|
||||
/// 所有书签
|
||||
public var bookmarks: [RDEPUBBookmark] {
|
||||
activeBookmarks
|
||||
}
|
||||
|
||||
/// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。
|
||||
public var annotations: [RDEPUBAnnotation] {
|
||||
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
|
||||
return merged.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
/// 原始目录树结构
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
publication?.tableOfContents ?? []
|
||||
}
|
||||
|
||||
/// 展平后的目录列表(用于目录面板展示,每个条目附带页码)
|
||||
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
|
||||
guard let publication else { return [] }
|
||||
return flattenedTableOfContentsItems(from: publication.tableOfContents)
|
||||
}
|
||||
|
||||
/// 当前阅读位置所在的目录项
|
||||
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
|
||||
resolvedCurrentTableOfContentsItem()
|
||||
}
|
||||
|
||||
// MARK: - 私有属性
|
||||
|
||||
/// EPUB 文件 URL
|
||||
let epubURL: URL
|
||||
|
||||
let persistence: RDEPUBReaderPersistence?
|
||||
|
||||
let dependencies: RDEPUBReaderDependencies
|
||||
|
||||
let readerView = RDReaderView()
|
||||
|
||||
let loadingIndicator: UIActivityIndicatorView = {
|
||||
UIActivityIndicatorView(style: .large)
|
||||
}()
|
||||
|
||||
let errorLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
@@ -121,97 +91,115 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
label.isHidden = true
|
||||
return label
|
||||
}()
|
||||
|
||||
let paginationHostView = UIView()
|
||||
|
||||
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
||||
|
||||
var parser: RDEPUBParser? {
|
||||
get { readerContext.parser }
|
||||
set { readerContext.parser = newValue }
|
||||
}
|
||||
|
||||
var publication: RDEPUBPublication? {
|
||||
get { readerContext.publication }
|
||||
set { readerContext.publication = newValue }
|
||||
}
|
||||
|
||||
var readingSession: RDEPUBReadingSession? {
|
||||
get { readerContext.readingSession }
|
||||
set { readerContext.readingSession = newValue }
|
||||
}
|
||||
|
||||
var textBook: RDEPUBTextBook? {
|
||||
get { readerContext.textBook }
|
||||
set { readerContext.textBook = newValue }
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readerContext.activePages
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readerContext.activeChapters
|
||||
}
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] {
|
||||
get { readerContext.activeBookmarks }
|
||||
set { readerContext.activeBookmarks = newValue }
|
||||
}
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] {
|
||||
get { readerContext.activeHighlights }
|
||||
set { readerContext.activeHighlights = newValue }
|
||||
}
|
||||
|
||||
lazy var topToolView = runtime.makeTopToolView()
|
||||
|
||||
lazy var bottomToolView = runtime.makeBottomToolView()
|
||||
|
||||
lazy var searchBarView = RDEPUBReaderSearchBarView()
|
||||
/// 搜索栏是否当前可见
|
||||
|
||||
private(set) var isSearchBarVisible = false
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
get { readerContext.currentBookIdentifier }
|
||||
set { readerContext.currentBookIdentifier = newValue }
|
||||
}
|
||||
|
||||
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { readerContext.currentBrightness }
|
||||
set { readerContext.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
var didStartInitialLoad: Bool {
|
||||
get { readerContext.didStartInitialLoad }
|
||||
set { readerContext.didStartInitialLoad = newValue }
|
||||
}
|
||||
|
||||
var isRepaginating: Bool {
|
||||
get { readerContext.isRepaginating }
|
||||
set { readerContext.isRepaginating = newValue }
|
||||
}
|
||||
|
||||
var lastTextPaginationPageSize: CGSize? {
|
||||
get { readerContext.lastTextPaginationPageSize }
|
||||
set { readerContext.lastTextPaginationPageSize = newValue }
|
||||
}
|
||||
|
||||
var isReconcilingTextPaginationSize = false
|
||||
|
||||
var paginationToken: UUID {
|
||||
get { readerContext.paginationToken }
|
||||
set { readerContext.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var paginator: RDEPUBPaginator? {
|
||||
get { readerContext.paginator }
|
||||
set { readerContext.paginator = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { readerContext.searchState }
|
||||
set { readerContext.searchState = newValue }
|
||||
}
|
||||
|
||||
var isExternalTextBook: Bool {
|
||||
get { readerContext.isExternalTextBook }
|
||||
set { readerContext.isExternalTextBook = newValue }
|
||||
}
|
||||
|
||||
var textFileURL: URL? {
|
||||
get { readerContext.textFileURL }
|
||||
set { readerContext.textFileURL = newValue }
|
||||
}
|
||||
|
||||
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
|
||||
|
||||
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
/// 使用 EPUB 文件 URL 初始化阅读器
|
||||
/// 会自动从持久化存储中恢复用户的阅读偏好设置
|
||||
/// - Parameters:
|
||||
/// - epubURL: EPUB 文件的本地 URL
|
||||
/// - configuration: 阅读器配置,默认使用 .default
|
||||
/// - persistence: 持久化策略,默认使用 UserDefaults
|
||||
public init(
|
||||
epubURL: URL,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
@@ -225,7 +213,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
self.dependencies = dependencies
|
||||
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
// Sync context after super.init
|
||||
|
||||
readerContext.dependencies = dependencies
|
||||
readerContext.configuration = self.configuration
|
||||
readerContext.epubURL = epubURL
|
||||
@@ -245,15 +233,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||
/// - Parameters:
|
||||
/// - textBook: 已分页的 TextBook 实例
|
||||
/// - bookIdentifier: 书籍唯一标识符,用于持久化
|
||||
/// - title: 书籍标题
|
||||
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
|
||||
/// - configuration: 阅读器配置
|
||||
/// - persistence: 持久化策略
|
||||
public convenience init(
|
||||
textBook: RDEPUBTextBook,
|
||||
bookIdentifier: String,
|
||||
@@ -317,10 +296,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||
}
|
||||
|
||||
// MARK: - 搜索栏管理
|
||||
|
||||
/// 显示搜索栏。工具栏可见时立即添加到视图层级并滑入动画;否则仅标记状态,
|
||||
/// 等待 `handleToolViewVisibilityChanged` 在工具栏显示时再安装。
|
||||
func showSearchBar() {
|
||||
guard !isSearchBarVisible else { return }
|
||||
isSearchBarVisible = true
|
||||
@@ -367,7 +342,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将搜索栏添加到 readerView 并执行滑入动画
|
||||
private func installSearchBarView() {
|
||||
guard searchBarView.superview == nil else { return }
|
||||
searchBarView.apply(theme: configuration.theme)
|
||||
@@ -403,8 +377,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏搜索栏,带动画滑出
|
||||
/// - Parameter clearSearch: 是否同时清除搜索状态
|
||||
func hideSearchBar(clearSearch: Bool = false) {
|
||||
guard isSearchBarVisible else { return }
|
||||
isSearchBarVisible = false
|
||||
@@ -425,7 +397,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏匹配计数
|
||||
func updateSearchCount() {
|
||||
guard let searchState else {
|
||||
searchBarView.showNoResults()
|
||||
@@ -438,7 +409,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||
func handleToolViewVisibilityChanged(isVisible: Bool) {
|
||||
if isVisible {
|
||||
if isSearchBarVisible {
|
||||
@@ -456,6 +426,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
|
||||
private extension RDEPUBReaderController {
|
||||
|
||||
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
|
||||
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
|
||||
searchSectionTitle(for: entry.element)
|
||||
|
||||
@@ -1,93 +1,34 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器委托协议
|
||||
|
||||
/// EPUB 阅读器的委托协议
|
||||
/// 用于向调用方通知阅读器的各种状态变更事件
|
||||
/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
/// 阅读器成功打开书籍时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - publication: 解析后的出版物对象
|
||||
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
|
||||
/// 阅读位置更新时调用(翻页、跳转等)
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - location: 新的阅读位置
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
|
||||
/// 阅读器翻到书籍末尾时调用
|
||||
/// - Parameter reader: 阅读器控制器
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
|
||||
/// 用户选中文本发生变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - selection: 当前选中文本,nil 表示取消选中
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
|
||||
/// 高亮标注列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - highlights: 当前所有高亮
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
|
||||
/// 书签列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - bookmarks: 当前所有书签
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
|
||||
/// 搜索结果更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - result: 搜索结果摘要,nil 表示无搜索
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
|
||||
/// 当前搜索匹配项变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - match: 当前匹配项,nil 表示无匹配
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
|
||||
/// 当前所在目录项更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - item: 当前目录项
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
|
||||
/// 用户点击外部链接时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 外部链接打开前的拦截钩子,返回 false 可阻止打开
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
/// - Returns: 是否允许打开该链接
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - error: 错误对象
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
|
||||
/// 允许调用方自定义顶部工具栏
|
||||
/// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - topToolView: 顶部工具栏视图
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
// MARK: - 默认空实现
|
||||
|
||||
/// 所有委托方法的默认空实现,调用方可只实现需要的方法
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 高亮管理面板
|
||||
|
||||
/// EPUB 阅读器的高亮标注管理控制器
|
||||
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个高亮位置时的回调
|
||||
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户编辑高亮批注后的回调
|
||||
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户删除高亮时的回调
|
||||
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
/// 高亮列表(按创建时间降序排列)
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
/// 当前主题配置
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
/// 提供章节标题的回调,用于显示高亮所在的章节名
|
||||
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
/// 筛选控件:全部、批注、高亮
|
||||
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
@@ -50,6 +45,8 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
configureFilterControl()
|
||||
@@ -87,6 +84,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
private func configureFilterControl() {
|
||||
filterControl.selectedSegmentIndex = 0
|
||||
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
|
||||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||||
navigationItem.titleView = filterControl
|
||||
}
|
||||
@@ -96,6 +94,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
@@ -109,6 +108,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.highlights.empty"
|
||||
tableView.backgroundView = label
|
||||
}
|
||||
|
||||
@@ -216,18 +216,16 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理面板
|
||||
|
||||
/// EPUB 阅读器的书签管理控制器
|
||||
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个书签位置时的回调
|
||||
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
/// 用户删除书签时的回调
|
||||
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
@@ -298,6 +296,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
|
||||
tableView.backgroundView = label
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 持久化协议
|
||||
|
||||
/// 阅读器持久化协议
|
||||
/// 定义了阅读位置、书签、高亮和用户设置的存储接口
|
||||
/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等)
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
/// 加载指定书籍的上次阅读位置
|
||||
/// - Parameter bookIdentifier: 书籍唯一标识符
|
||||
/// - Returns: 保存的阅读位置,nil 表示首次打开
|
||||
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
|
||||
/// 保存指定书籍的当前阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 阅读位置
|
||||
/// - bookIdentifier: 书籍唯一标识符
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有书签
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
|
||||
/// 保存指定书籍的所有书签
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有高亮标注
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
|
||||
/// 保存指定书籍的所有高亮标注
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
|
||||
/// 加载全局阅读器用户设置(字号、行距、主题等)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
|
||||
/// 保存全局阅读器用户设置
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
#if DEBUG
|
||||
@@ -68,30 +47,18 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults 持久化实现
|
||||
|
||||
/// 基于 UserDefaults 的持久化实现
|
||||
/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置
|
||||
/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
/// UserDefaults 实例
|
||||
|
||||
private let defaults: UserDefaults
|
||||
/// 阅读位置的 key 前缀
|
||||
|
||||
private let locationPrefix: String
|
||||
/// 书签列表的 key 前缀
|
||||
|
||||
private let bookmarksPrefix: String
|
||||
/// 高亮列表的 key 前缀
|
||||
|
||||
private let highlightsPrefix: String
|
||||
/// 用户设置的 key
|
||||
|
||||
private let settingsKey: String
|
||||
|
||||
/// 初始化 UserDefaults 持久化策略
|
||||
/// - Parameters:
|
||||
/// - defaults: UserDefaults 实例,默认 .standard
|
||||
/// - locationPrefix: 阅读位置 key 前缀
|
||||
/// - bookmarksPrefix: 书签 key 前缀
|
||||
/// - highlightsPrefix: 高亮 key 前缀
|
||||
/// - settingsKey: 用户设置 key
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBReaderSearchSection: Equatable {
|
||||
|
||||
struct Item: Equatable {
|
||||
|
||||
let matchIndex: Int
|
||||
|
||||
let previewText: String
|
||||
|
||||
let isCurrent: Bool
|
||||
}
|
||||
|
||||
let title: String
|
||||
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// MARK: - 搜索面板
|
||||
|
||||
/// 阅读器搜索面板
|
||||
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
|
||||
var onSearchTextChanged: ((String) -> Void)?
|
||||
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
|
||||
var onSearchNext: (() -> Void)?
|
||||
|
||||
var onSelectMatch: ((Int) -> Void)?
|
||||
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
let textField: UITextField = {
|
||||
@@ -39,23 +46,35 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}()
|
||||
|
||||
private let backgroundButton = UIButton(type: .custom)
|
||||
|
||||
private let panelView = UIView()
|
||||
|
||||
private let grabberView = UIView()
|
||||
|
||||
private let searchRowView = UIView()
|
||||
|
||||
private let searchFieldContainer = UIView()
|
||||
|
||||
private let searchIcon = UIImageView()
|
||||
|
||||
private let searchFieldDivider = UIView()
|
||||
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
// Preserve legacy accessibility hooks used by existing tests and demo logic.
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let countLabel = UILabel()
|
||||
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
|
||||
private var keyword = ""
|
||||
|
||||
private var currentMatchIndex: Int?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -221,7 +240,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
searchFieldContainer.addSubview(searchIcon)
|
||||
searchFieldContainer.addSubview(textField)
|
||||
|
||||
// Legacy shims
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
@@ -405,6 +423,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_searchIsDarkBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
@@ -419,6 +438,7 @@ private extension UIColor {
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
searchSections.count
|
||||
}
|
||||
@@ -485,6 +505,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
|
||||
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onSearchTextChanged?("")
|
||||
@@ -494,15 +515,21 @@ extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
}
|
||||
|
||||
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
|
||||
|
||||
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||
|
||||
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
|
||||
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
|
||||
|
||||
static var highlightTextColor = UIColor.systemBlue
|
||||
|
||||
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
private let cardView = UIView()
|
||||
|
||||
private let previewLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 目录条目
|
||||
|
||||
/// 展平后的目录条目结构体
|
||||
/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
/// 章节标题
|
||||
|
||||
public var title: String
|
||||
/// 章节的 href(相对于 EPUB 容器根目录)
|
||||
|
||||
public var href: String
|
||||
/// 目录层级深度(0 为顶级章节),用于列表缩进显示
|
||||
|
||||
public var depth: Int
|
||||
/// 该章节所在页码(从 1 开始),nil 表示无法确定
|
||||
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 工具栏基类
|
||||
|
||||
/// 阅读器工具栏的基类
|
||||
/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
/// 分隔线视图
|
||||
|
||||
private let lineView = UIView()
|
||||
/// 分隔线高度
|
||||
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -25,24 +21,15 @@ open class RDEPUBReaderToolView: UIView {
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
/// 应用主题配色到工具栏
|
||||
/// - Parameter theme: 阅读器主题配置
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
/// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部)
|
||||
/// - Parameter bounds: 工具栏的 bounds
|
||||
/// - Returns: 分隔线的 frame
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具栏按钮
|
||||
|
||||
/// 阅读器工具栏专用按钮
|
||||
/// 继承自 UIButton,用于工具栏中的功能按钮
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 顶部工具栏
|
||||
|
||||
/// 阅读器顶部工具栏
|
||||
/// 显示书名、返回按钮和书签按钮
|
||||
/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
/// 返回按钮点击回调
|
||||
|
||||
var onBack: (() -> Void)?
|
||||
/// 书签按钮点击回调
|
||||
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
/// 搜索按钮点击回调
|
||||
|
||||
var onSearch: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
struct RDEPUBViewportSignature: Equatable {
|
||||
/// 视口宽度(pt)
|
||||
|
||||
let width: CGFloat
|
||||
/// 视口高度(pt)
|
||||
|
||||
let height: CGFloat
|
||||
/// 顶部安全区域高度
|
||||
|
||||
let safeTop: CGFloat
|
||||
/// 左侧安全区域宽度
|
||||
|
||||
let safeLeft: CGFloat
|
||||
/// 底部安全区域高度
|
||||
|
||||
let safeBottom: CGFloat
|
||||
/// 右侧安全区域宽度
|
||||
|
||||
let safeRight: CGFloat
|
||||
|
||||
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
|
||||
@@ -26,13 +24,11 @@ struct RDEPUBViewportSignature: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
enum RDEPUBViewportChangeReason {
|
||||
/// 视图布局变化(如 Safe Area 更新、分屏调整等)
|
||||
|
||||
case viewLayout
|
||||
/// 屏幕方向旋转过渡
|
||||
|
||||
case orientationTransition
|
||||
}
|
||||
|
||||
/// 原生文本渲染路径的分页快照,包含所有页面和章节信息
|
||||
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Web 内容视图代理
|
||||
|
||||
/// Web 内容视图的代理协议
|
||||
/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
/// WebView 中阅读位置更新时调用
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// WebView 中文本选择变化时调用
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 用户从选择菜单中触发操作
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 用户点击内部链接(章节跳转)
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 用户点击外部链接
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
/// WebView 中 JavaScript 执行出错
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图
|
||||
|
||||
/// EPUB 固定布局和 Web 渲染路径的内容视图
|
||||
/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制
|
||||
/// 适用于固定布局 EPUB 和不支持 Native Text 的场景
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB WebView 路径的原生装饰覆盖层。
|
||||
/// 用于在 WebView 上方叠加高亮、下划线等视觉装饰。
|
||||
/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。
|
||||
final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
private let verticalAdjustment: CGFloat = -1
|
||||
@@ -18,8 +15,6 @@ final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 应用装饰数据并触发重绘
|
||||
/// - Parameter decorations: 装饰项数组(高亮、下划线等)
|
||||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||
self.decorations = decorations.filter { !$0.rects.isEmpty }
|
||||
setNeedsDisplay()
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
//
|
||||
// RDURLReaderController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
|
||||
// 该文件是 ReadViewSDK 的最简使用入口:
|
||||
// - .epub 文件:直接创建 RDEPUBReaderController
|
||||
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
|
||||
// - 分页失败时回退到纯文本 UITextView 展示
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// URL 阅读器入口控制器
|
||||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```swift
|
||||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private struct PendingDemoPageRequest {
|
||||
let pageNumber: Int
|
||||
@@ -31,11 +8,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
var attemptCount: Int
|
||||
}
|
||||
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
/// 内嵌的阅读器控制器
|
||||
|
||||
private var embeddedController: UIViewController?
|
||||
private let demoStateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
@@ -58,10 +34,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
private var lastActivatedExternalURL: URL?
|
||||
private var lastReaderErrorDescription = "none"
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - bookURL: 书籍文件的本地 URL
|
||||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||||
public init(
|
||||
bookURL: URL,
|
||||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||||
@@ -76,7 +48,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@@ -101,18 +72,11 @@ public final class RDURLReaderController: UIViewController {
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
/// 跳转到指定页码(Demo 用)
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||||
@@ -125,12 +89,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
return moved
|
||||
}
|
||||
|
||||
/// 执行翻页模式切换序列(Demo 用)
|
||||
/// 按照指定顺序和延迟依次切换翻页模式
|
||||
/// - Parameters:
|
||||
/// - displayTypes: 翻页模式数组
|
||||
/// - initialPageNumber: 可选的初始跳转页码
|
||||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||||
public func runDemoDisplaySequence(
|
||||
_ displayTypes: [RDReaderView.DisplayType],
|
||||
initialPageNumber: Int? = nil,
|
||||
@@ -151,9 +109,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行搜索(Demo 用)
|
||||
/// 显示搜索栏并提交关键词。若阅读器尚未完成加载则延迟到 `didUpdateLocation` 后执行。
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
public func performDemoSearch(keyword: String) {
|
||||
guard let readerController else { return }
|
||||
readerController.showSearchBar()
|
||||
@@ -187,8 +142,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
submitSearchAfterDelay(keyword: keyword, retries: 5)
|
||||
}
|
||||
|
||||
/// 嵌入阅读器控制器到当前视图层级
|
||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
@@ -224,7 +177,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
// 分页失败时回退到纯文本展示
|
||||
|
||||
let fallback = UIViewController()
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
@@ -248,7 +201,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||||
private var readerController: RDEPUBReaderController? {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
@@ -322,7 +274,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
let location = readerController.currentLocation
|
||||
@@ -372,6 +323,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
let location = readerController?.currentLocation
|
||||
let href = encodedDemoLocationHref(location?.href)
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let cfi = encodedDemoField(location?.cfi)
|
||||
let lastCFI = encodedDemoField(location?.lastCFI)
|
||||
let rangeCFI = encodedDemoField(location?.rangeCFI)
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
|
||||
@@ -388,6 +342,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
"selection=\(selection)",
|
||||
"href=\(href)",
|
||||
"progression=\(progression)",
|
||||
"cfi=\(cfi)",
|
||||
"lastCFI=\(lastCFI)",
|
||||
"rangeCFI=\(rangeCFI)",
|
||||
"mode=\(mapSnapshot.mode)",
|
||||
"pagination=\(mapSnapshot.phase)",
|
||||
"knownPages=\(mapSnapshot.knownPages)",
|
||||
@@ -464,14 +421,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸。
|
||||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
||||
/// 以保持与页面展示时的内容几何一致。
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
UIScreen.main.bounds.size
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||||
@@ -483,8 +436,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 解码文本文件内容
|
||||
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
@@ -536,9 +487,8 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
private extension RDReaderView.DisplayType {
|
||||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||||
|
||||
var demoArgumentValue: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
|
||||
+11
@@ -1,22 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
|
||||
let threadName = resolvedThreadName()
|
||||
|
||||
let queueLabel = resolvedQueueLabel()
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
|
||||
}
|
||||
|
||||
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
|
||||
|
||||
let startedAt = CFAbsoluteTimeGetCurrent()
|
||||
log(scope, "START \(message)")
|
||||
do {
|
||||
|
||||
let result = try work()
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
|
||||
return result
|
||||
} catch {
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
|
||||
throw error
|
||||
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
|
||||
}
|
||||
|
||||
private static func resolvedThreadName() -> String {
|
||||
|
||||
if let name = Thread.current.name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
return "main"
|
||||
}
|
||||
|
||||
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user