import Foundation public struct RDEPUBLocation: Codable, Equatable { /// 书籍唯一标识符,用于隔离不同书的阅读进度 public var bookIdentifier: String? /// OPF 相对路径,对应 spine 中的资源文件 public var href: String /// 视口起始位置在章节中的相对进度 [0, 1] public var progression: Double /// 视口末尾位置在章节中的相对进度 [0, 1](可选) public var lastProgression: Double? /// 锚点标识符(如 #section1),用于精确跳转 public var fragment: String? /// 文本范围锚点(用于文本 EPUB 的高亮定位) public var rangeAnchor: RDEPUBTextRangeAnchor? public init( bookIdentifier: String? = nil, href: String, progression: Double, lastProgression: Double? = nil, fragment: String? = nil, rangeAnchor: RDEPUBTextRangeAnchor? = nil ) { self.bookIdentifier = bookIdentifier self.href = href self.progression = Self.clamp(progression) self.lastProgression = lastProgression.map(Self.clamp) self.fragment = fragment?.nilIfEmpty self.rangeAnchor = rangeAnchor } /// 导航用的中间进度值,取 progression 和 lastProgression 的中点 /// 用于在字号/横竖屏变化后估算最接近的页号 public var navigationProgression: Double { let end = lastProgression ?? progression if end.isNaN || end.isInfinite { return progression } return Self.clamp((progression + end) / 2.0) } /// 将进度值限制在 [0, 1] 范围内,防止越界 private static func clamp(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(1, max(0, value)) } } /// 视口中的单个资源信息,表示当前屏幕可见区域覆盖的某个 spine 资源 public struct RDEPUBViewportResource: Codable, Equatable { /// 资源路径(相对于 OPF 目录) public var href: String /// 对应的 spine 索引 public var spineIndex: Int /// 该资源在视口中的起始进度 [0, 1] public var progression: Double /// 该资源在视口中的结束进度 [0, 1] public var lastProgression: Double? /// 锚点标识符 public var fragment: String? public init( href: String, spineIndex: Int, progression: Double, lastProgression: Double? = nil, fragment: String? = nil ) { self.href = href self.spineIndex = spineIndex self.progression = progression self.lastProgression = lastProgression self.fragment = fragment } } /// 视口模型,描述当前屏幕可见区域的完整状态 /// 可能跨越多个 spine 资源(如分栏布局时) public struct RDEPUBViewport: Codable, Equatable { /// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景) public var resources: [RDEPUBViewportResource] /// 当前可见的页码 public var visiblePageNumber: Int /// 当前章节索引 public var chapterIndex: Int? /// 是否为固定版式内容 public var isFixedLayout: Bool public init( resources: [RDEPUBViewportResource], visiblePageNumber: Int, chapterIndex: Int? = nil, isFixedLayout: Bool ) { self.resources = resources self.visiblePageNumber = visiblePageNumber self.chapterIndex = chapterIndex self.isFixedLayout = isFixedLayout } } /// 阅读上下文,聚合了位置、视口和页码等完整阅读状态 /// 由 RDEPUBReadingSession 维护,是翻页容器层获取当前阅读状态的主要数据源 public struct RDEPUBReadingContext: Codable, Equatable { /// 当前阅读位置(href + progression) public var location: RDEPUBLocation /// 当前视口状态 public var viewport: RDEPUBViewport /// 当前扁平页码(跨章节累计) public var pageNumber: Int /// 当前章节索引 public var chapterIndex: Int? public init( location: RDEPUBLocation, viewport: RDEPUBViewport, pageNumber: Int, chapterIndex: Int? = nil ) { self.location = location self.viewport = viewport self.pageNumber = pageNumber self.chapterIndex = chapterIndex } } /// 文本选择模型,由 JS 桥接 ssReaderSelectionChanged 消息触发创建 private extension String { var nilIfEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } }