// // 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 } /// 标注菜单操作类型,用于用户长按选中文本后的操作选项 public enum RDEPUBAnnotationMenuAction: Equatable { case copy case highlight case annotate } /// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位 /// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容 public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { /// 类型标识符,固定为 "text-offset" public var kind: String /// 资源路径 public var href: String /// 起始字符偏移量 public var start: Int /// 结束字符偏移量 public var end: Int /// 初始化文本偏移范围 /// - Parameters: /// - href: 资源路径 /// - start: 起始字符偏移量 /// - end: 结束字符偏移量 public init(href: String, start: Int, end: Int) { self.kind = "text-offset" self.href = href self.start = start self.end = end } /// 转换为 NSRange(用于 NSAttributedString 操作) public var nsRange: NSRange? { guard end > start else { return nil } return NSRange(location: start, length: end - start) } /// 序列化为 JSON 字符串 public func jsonString() -> String? { guard let data = try? JSONEncoder().encode(self) else { return nil } return String(data: data, encoding: .utf8) } /// 从 JSON 字符串反序列化,校验 kind 和范围有效性 public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { guard let string, let data = string.data(using: .utf8), let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data), rangeInfo.kind == "text-offset", rangeInfo.end > rangeInfo.start else { return nil } return rangeInfo } } /// 文本分页截断原因,描述为什么在当前位置分页 public enum RDEPUBTextPageBreakReason: String, Codable, Equatable { /// 章节自然结束 case chapterEnd /// 达到帧容量限制 case frameLimit /// 块级元素边界(如段落、标题) case blockBoundary /// 附件(图片等)边界 case attachmentBoundary /// 语义边界(如列表、引用块) case semanticBoundary } /// 文本附件类型 public enum RDEPUBTextAttachmentKind: String, Codable, Equatable { /// 图片附件 case image /// 通用附件 case generic } /// 文本页元数据,记录分页时的上下文信息 /// 用于调试分页问题和优化分页质量 public struct RDEPUBTextPageMetadata: Codable, Equatable { /// 分页截断原因 public var breakReason: RDEPUBTextPageBreakReason /// 本页覆盖的块级元素范围 public var blockRange: NSRange? /// 本页包含的附件范围列表 public var attachmentRanges: [NSRange] /// 附件类型列表 public var attachmentKinds: [RDEPUBTextAttachmentKind] /// 块级元素类型列表 public var blockKinds: [RDEPUBTextBlockKind] /// 语义提示信息 public var semanticHints: [RDEPUBTextSemanticHint] /// 附件布局位置信息 public var attachmentPlacements: [RDEPUBTextAttachmentPlacement] /// 本页末尾的 fragment ID(用于恢复位置) public var trailingFragmentID: String? /// 诊断信息列表(调试用) public var diagnostics: [String] public init( breakReason: RDEPUBTextPageBreakReason, blockRange: NSRange? = nil, attachmentRanges: [NSRange] = [], attachmentKinds: [RDEPUBTextAttachmentKind] = [], blockKinds: [RDEPUBTextBlockKind] = [], semanticHints: [RDEPUBTextSemanticHint] = [], attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [], trailingFragmentID: String? = nil, diagnostics: [String] = [] ) { self.breakReason = breakReason self.blockRange = blockRange self.attachmentRanges = attachmentRanges self.attachmentKinds = attachmentKinds self.blockKinds = blockKinds self.semanticHints = semanticHints self.attachmentPlacements = attachmentPlacements self.trailingFragmentID = trailingFragmentID self.diagnostics = diagnostics } } /// 高亮模型,表示用户在阅读中标记的文本片段 /// 支持 Web EPUB(DOM Range 定位)和文本 EPUB(字符偏移定位)两种模式 public struct RDEPUBHighlight: Codable, Equatable { /// 高亮唯一标识符 public var id: String /// 书籍标识符 public var bookIdentifier: String? /// 高亮发生的位置 public var location: RDEPUBLocation /// 高亮的文本内容 public var text: String /// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON) public var rangeInfo: String? /// 高亮样式(背景色高亮或下划线) public var style: RDEPUBHighlightStyle /// 高亮颜色(CSS 颜色值,如 "#F8E16C") public var color: String /// 用户备注 public var note: String? /// 创建时间 public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, location: RDEPUBLocation, text: String, rangeInfo: String? = nil, style: RDEPUBHighlightStyle = .highlight, color: String = "#F8E16C", note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.location = location self.text = text self.rangeInfo = rangeInfo?.nilIfEmpty self.style = style self.color = color self.note = note?.nilIfEmpty self.createdAt = createdAt } /// 是否包含用户备注 public var hasNote: Bool { note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } /// 编码键,用于 Codable 序列化/反序列化 private enum CodingKeys: String, CodingKey { case id case bookIdentifier case location case text case rangeInfo case style case color case note case createdAt } /// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier) location = try container.decode(RDEPUBLocation.self, forKey: .location) text = try container.decode(String.self, forKey: .text) rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { style = decodedStyle } else { style = .highlight } color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } /// 自定义编码器 public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier) try container.encode(location, forKey: .location) try container.encode(text, forKey: .text) try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo) try container.encode(style, forKey: .style) try container.encode(color, forKey: .color) try container.encodeIfPresent(note, forKey: .note) try container.encode(createdAt, forKey: .createdAt) } } /// 书签模型,记录用户标记的阅读位置 public 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 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 } }