Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBReadingModels.swift
T
2026-05-26 09:19:37 +08:00

720 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RDEPUBReadingModels.swift
// RDReaderView
//
// EPUBCore 层阅读会话相关数据模型定义文件。
// 包含阅读位置(Location)、视口(Viewport)、阅读上下文(ReadingContext)、
// 文本选择(Selection)、高亮(Highlight)、书签(Bookmark)、
// 章节信息(ChapterInfo)、页面模型(Page)、固定版式 Spread 模型等。
// 这些模型由 RDEPUBReadingSession 管理,贯穿整个阅读生命周期。
//
import Foundation
/// 阅读位置模型,EPUB 是可重排内容,使用 href + progression 而非页号来定位
/// progression 取值 [0, 1],表示当前视口在章节中的相对位置
public struct RDEPUBLocation: Codable, Equatable {
/// 书籍唯一标识符,用于隔离不同书的阅读进度
public var bookIdentifier: String?
/// OPF 相对路径,对应 spine 中的资源文件
public var href: String
/// 视口起始位置在章节中的相对进度 [0, 1]
public var progression: Double
/// 视口末尾位置在章节中的相对进度 [0, 1](可选)
public var lastProgression: Double?
/// 锚点标识符(如 #section1),用于精确跳转
public var fragment: String?
/// 文本范围锚点(用于文本 EPUB 的高亮定位)
public var rangeAnchor: RDEPUBTextRangeAnchor?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil,
rangeAnchor: RDEPUBTextRangeAnchor? = nil
) {
self.bookIdentifier = bookIdentifier
self.href = href
self.progression = Self.clamp(progression)
self.lastProgression = lastProgression.map(Self.clamp)
self.fragment = fragment?.nilIfEmpty
self.rangeAnchor = rangeAnchor
}
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
/// 用于在字号/横竖屏变化后估算最接近的页号
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
return progression
}
return Self.clamp((progression + end) / 2.0)
}
/// 将进度值限制在 [0, 1] 范围内,防止越界
private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(1, max(0, value))
}
}
/// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源
public struct RDEPUBViewportResource: Codable, Equatable {
/// 资源路径(相对于 OPF 目录)
public var href: String
/// 对应的 spine 索引
public var spineIndex: Int
/// 该资源在视口中的起始进度 [0, 1]
public var progression: Double
/// 该资源在视口中的结束进度 [0, 1]
public var lastProgression: Double?
/// 锚点标识符
public var fragment: String?
public init(
href: String,
spineIndex: Int,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil
) {
self.href = href
self.spineIndex = spineIndex
self.progression = progression
self.lastProgression = lastProgression
self.fragment = fragment
}
}
/// 视口模型,描述当前屏幕可见区域的完整状态
/// 可能跨越多个 spine 资源(如分栏布局时)
public struct RDEPUBViewport: Codable, Equatable {
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
public var resources: [RDEPUBViewportResource]
/// 当前可见的页码
public var visiblePageNumber: Int
/// 当前章节索引
public var chapterIndex: Int?
/// 是否为固定版式内容
public var isFixedLayout: Bool
public init(
resources: [RDEPUBViewportResource],
visiblePageNumber: Int,
chapterIndex: Int? = nil,
isFixedLayout: Bool
) {
self.resources = resources
self.visiblePageNumber = visiblePageNumber
self.chapterIndex = chapterIndex
self.isFixedLayout = isFixedLayout
}
}
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
/// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源
public struct RDEPUBReadingContext: Codable, Equatable {
/// 当前阅读位置(href + progression
public var location: RDEPUBLocation
/// 当前视口状态
public var viewport: RDEPUBViewport
/// 当前扁平页码(跨章节累计)
public var pageNumber: Int
/// 当前章节索引
public var chapterIndex: Int?
public init(
location: RDEPUBLocation,
viewport: RDEPUBViewport,
pageNumber: Int,
chapterIndex: Int? = nil
) {
self.location = location
self.viewport = viewport
self.pageNumber = pageNumber
self.chapterIndex = chapterIndex
}
}
/// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建
public struct RDEPUBSelection: Codable, Equatable {
/// 书籍标识符
public var bookIdentifier: String?
/// 选择发生的位置
public var location: RDEPUBLocation
/// 选中的文本内容
public var text: String
/// DOM Range 信息(JSON 序列化字符串,用于精确重建选区)
public var rangeInfo: String?
/// 选择创建时间
public var createdAt: Date
public init(
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
createdAt: Date = Date()
) {
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.createdAt = createdAt
}
/// 判断是否为空选择(无文本或无范围信息)
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
}
}
/// 高亮样式类型
/// - highlight: 背景色高亮
/// - underline: 下划线标记
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
/// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位
/// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// 类型标识符,固定为 "text-offset"
public var kind: String
/// 资源路径
public var href: String
/// 起始字符偏移量
public var start: Int
/// 结束字符偏移量
public var end: Int
/// 初始化文本偏移范围
/// - Parameters:
/// - href: 资源路径
/// - start: 起始字符偏移量
/// - end: 结束字符偏移量
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
self.start = start
self.end = end
}
/// 转换为 NSRange(用于 NSAttributedString 操作)
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
/// 序列化为 JSON 字符串
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
/// 从 JSON 字符串反序列化,校验 kind 和范围有效性
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string,
let data = string.data(using: .utf8),
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
rangeInfo.kind == "text-offset",
rangeInfo.end > rangeInfo.start else {
return nil
}
return rangeInfo
}
}
/// 文本分页截断原因,描述为什么在当前位置分页
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
/// 章节自然结束
case chapterEnd
/// 达到帧容量限制
case frameLimit
/// 块级元素边界(如段落、标题)
case blockBoundary
/// 附件(图片等)边界
case attachmentBoundary
/// 语义边界(如列表、引用块)
case semanticBoundary
}
/// 文本附件类型
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
/// 图片附件
case image
/// 通用附件
case generic
}
/// 文本页元数据,记录分页时的上下文信息
/// 用于调试分页问题和优化分页质量
public struct RDEPUBTextPageMetadata: Codable, Equatable {
/// 分页截断原因
public var breakReason: RDEPUBTextPageBreakReason
/// 本页覆盖的块级元素范围
public var blockRange: NSRange?
/// 本页包含的附件范围列表
public var attachmentRanges: [NSRange]
/// 附件类型列表
public var attachmentKinds: [RDEPUBTextAttachmentKind]
/// 块级元素类型列表
public var blockKinds: [RDEPUBTextBlockKind]
/// 语义提示信息
public var semanticHints: [RDEPUBTextSemanticHint]
/// 附件布局位置信息
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 本页末尾的 fragment ID(用于恢复位置)
public var trailingFragmentID: String?
/// 诊断信息列表(调试用)
public var diagnostics: [String]
public init(
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange? = nil,
attachmentRanges: [NSRange] = [],
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
blockKinds: [RDEPUBTextBlockKind] = [],
semanticHints: [RDEPUBTextSemanticHint] = [],
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
trailingFragmentID: String? = nil,
diagnostics: [String] = []
) {
self.breakReason = breakReason
self.blockRange = blockRange
self.attachmentRanges = attachmentRanges
self.attachmentKinds = attachmentKinds
self.blockKinds = blockKinds
self.semanticHints = semanticHints
self.attachmentPlacements = attachmentPlacements
self.trailingFragmentID = trailingFragmentID
self.diagnostics = diagnostics
}
}
/// 高亮模型,表示用户在阅读中标记的文本片段
/// 支持 Web EPUBDOM Range 定位)和文本 EPUB(字符偏移定位)两种模式
public struct RDEPUBHighlight: Codable, Equatable {
/// 高亮唯一标识符
public var id: String
/// 书籍标识符
public var bookIdentifier: String?
/// 高亮发生的位置
public var location: RDEPUBLocation
/// 高亮的文本内容
public var text: String
/// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON)
public var rangeInfo: String?
/// 高亮样式(背景色高亮或下划线)
public var style: RDEPUBHighlightStyle
/// 高亮颜色(CSS 颜色值,如 "#F8E16C"
public var color: String
/// 用户备注
public var note: String?
/// 创建时间
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight,
color: String = "#F8E16C",
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.style = style
self.color = color
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
/// 是否包含用户备注
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// 编码键,用于 Codable 序列化/反序列化
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
case location
case text
case rangeInfo
case style
case color
case note
case createdAt
}
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
location = try container.decode(RDEPUBLocation.self, forKey: .location)
text = try container.decode(String.self, forKey: .text)
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle
} else {
style = .highlight
}
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
/// 自定义编码器
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
try container.encode(location, forKey: .location)
try container.encode(text, forKey: .text)
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(createdAt, forKey: .createdAt)
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(highlight: self)
}
}
/// 书签模型,记录用户标记的阅读位置
public struct RDEPUBBookmark: Codable, Equatable {
/// 书签唯一标识符
public var id: String
/// 书籍标识符
public var bookIdentifier: String?
/// 书签位置
public var location: RDEPUBLocation
/// 章节标题(用于书签列表展示)
public var chapterTitle: String?
/// 用户备注
public var note: String?
/// 创建时间
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
/// 是否包含用户备注
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(bookmark: self)
}
}
/// 统一标注模型,对标 WXRead 的 WRBookmark。
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var kind: RDEPUBAnnotationKind
public var location: RDEPUBLocation
public var text: String?
public var rangeInfo: String?
public var color: String?
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
kind: RDEPUBAnnotationKind,
location: RDEPUBLocation,
text: String? = nil,
rangeInfo: String? = nil,
color: String? = nil,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.kind = kind
self.location = location
self.text = text?.nilIfEmpty
self.rangeInfo = rangeInfo?.nilIfEmpty
self.color = color?.nilIfEmpty
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public init(highlight: RDEPUBHighlight) {
self.init(
id: highlight.id,
bookIdentifier: highlight.bookIdentifier,
kind: highlight.style == .underline ? .underline : .highlight,
location: highlight.location,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
color: highlight.color,
chapterTitle: nil,
note: highlight.note,
createdAt: highlight.createdAt
)
}
public init(bookmark: RDEPUBBookmark) {
self.init(
id: bookmark.id,
bookIdentifier: bookmark.bookIdentifier,
kind: .bookmark,
location: bookmark.location,
text: nil,
rangeInfo: nil,
color: nil,
chapterTitle: bookmark.chapterTitle,
note: bookmark.note,
createdAt: bookmark.createdAt
)
}
public var bookmark: RDEPUBBookmark? {
guard kind == .bookmark else { return nil }
return RDEPUBBookmark(
id: id,
bookIdentifier: bookIdentifier,
location: location,
chapterTitle: chapterTitle,
note: note,
createdAt: createdAt
)
}
public var highlight: RDEPUBHighlight? {
guard kind == .highlight || kind == .underline,
let text else {
return nil
}
return RDEPUBHighlight(
id: id,
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo,
style: kind == .underline ? .underline : .highlight,
color: color ?? "#F8E16C",
note: note,
createdAt: createdAt
)
}
}
/// 章节信息模型,描述单个章节的元数据
public struct EPUBChapterInfo: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 章节标题
public var title: String
/// 该章节的总页数
public var pageCount: Int
public init(spineIndex: Int, title: String, pageCount: Int) {
self.spineIndex = spineIndex
self.title = title
self.pageCount = pageCount
}
}
/// 页面模型,描述单页的完整信息
/// 由 RDEPUBReadingSession 根据分页结果构建,是翻页容器层的数据单元
public struct EPUBPage: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 章节索引(从 0 开始的章节序号)
public var chapterIndex: Int
/// 页在章节中的索引(从 0 开始)
public var pageIndexInChapter: Int
/// 该章节的总页数
public var totalPagesInChapter: Int
/// 章节标题
public var chapterTitle: String
/// 固定版式 Spread 数据(仅 fixed layout 书籍有值)
public var fixedSpread: EPUBFixedSpread?
public init(
spineIndex: Int,
chapterIndex: Int,
pageIndexInChapter: Int,
totalPagesInChapter: Int,
chapterTitle: String,
fixedSpread: EPUBFixedSpread?
) {
self.spineIndex = spineIndex
self.chapterIndex = chapterIndex
self.pageIndexInChapter = pageIndexInChapter
self.totalPagesInChapter = totalPagesInChapter
self.chapterTitle = chapterTitle
self.fixedSpread = fixedSpread
}
}
/// 固定版式 Spread 中的单个资源
public struct EPUBFixedSpreadResource: Codable, Equatable {
/// 对应的 spine 索引
public var spineIndex: Int
/// 资源路径
public var href: String
/// 资源标题
public var title: String
/// 在 spread 中的位置偏好
public var pageSpread: RDEPUBPageSpread?
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.pageSpread = pageSpread
}
}
/// 固定版式 Spread(双页展开)模型
/// 将 1-2 个 spine 资源组合为一个显示单元
public struct EPUBFixedSpread: Codable, Equatable {
/// 该 spread 包含的资源列表(通常 1-2 个)
public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources
}
/// 主资源(列表中的第一个),用于标识和定位
public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
}
/// 判断指定 href 的资源是否属于此 spread
/// - Parameters:
/// - normalizedHref: 标准化后的 href
/// - normalizer: href 标准化函数
/// - Returns: 是否包含该资源
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in
normalizer(resource.href) == normalizedHref
}
}
/// 根据 spine 列表和 spread 设置生成 Spread 数组
/// 根据 pageSpread 属性(left/right/center)决定资源配对
/// - Parameters:
/// - spine: spine 项列表
/// - spreadEnabled: 是否启用 spread 模式
/// - Returns: spread 数组
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
let resources = spine.enumerated().map { index, item in
EPUBFixedSpreadResource(
spineIndex: index,
href: item.href,
title: item.title,
pageSpread: item.pageSpread
)
}
guard spreadEnabled, resources.count > 1 else {
return resources.map { EPUBFixedSpread(resources: [$0]) }
}
var spreads: [EPUBFixedSpread] = []
var cursor = 0
while cursor < resources.count {
let current = resources[cursor]
guard cursor + 1 < resources.count else {
spreads.append(EPUBFixedSpread(resources: [current]))
break
}
let next = resources[cursor + 1]
if shouldPair(current: current, next: next) {
spreads.append(EPUBFixedSpread(resources: [current, next]))
cursor += 2
} else {
spreads.append(EPUBFixedSpread(resources: [current]))
cursor += 1
}
}
return spreads
}
/// 便捷方法:从 parser 构建 spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
}
/// 判断两个资源是否应该配对为一个 spread
/// center 类型不参与配对,left+right 或 right+left 配对
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
if current.pageSpread == .center || next.pageSpread == .center {
return false
}
switch (current.pageSpread, next.pageSpread) {
case (.left, .right), (.right, .left):
return true
case (.left, .left), (.right, .right):
return false
default:
return true
}
}
}
/// String 扩展:空字符串转为 nil,用于 Codable 容错处理
private extension String {
/// 空白字符修剪后如果是空字符串则返回 nil
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}