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( 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 } } /// 高亮样式类型 /// - highlight: 背景色高亮 /// - underline: 下划线标记 public enum RDEPUBHighlightStyle: String, Codable { case highlight case underline } /// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。 public enum RDEPUBAnnotationKind: String, Codable, Equatable { case bookmark case highlight case underline } /// 标注菜单操作类型,用于用户长按选中文本后的操作选项 public enum RDEPUBAnnotationMenuAction: Equatable { case copy case highlight case annotate } /// 文本偏移范围信息,用于文本 EPUB 的高亮精确定位 /// 使用字符偏移量而非 DOM Range,适用于 DTCoreText 渲染的纯文本内容 public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable { /// 类型标识符,固定为 "text-offset" public var kind: String /// 资源路径 public var href: String /// 起始字符偏移量 public var start: Int /// 结束字符偏移量 public var end: Int /// 初始化文本偏移范围 /// - Parameters: /// - href: 资源路径 /// - start: 起始字符偏移量 /// - end: 结束字符偏移量 public init(href: String, start: Int, end: Int) { self.kind = "text-offset" self.href = href self.start = start self.end = end } /// 转换为 NSRange(用于 NSAttributedString 操作) public var nsRange: NSRange? { guard end > start else { return nil } return NSRange(location: start, length: end - start) } /// 序列化为 JSON 字符串 public func jsonString() -> String? { guard let data = try? JSONEncoder().encode(self) else { return nil } return String(data: data, encoding: .utf8) } /// 从 JSON 字符串反序列化,校验 kind 和范围有效性 public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? { guard let string, let data = string.data(using: .utf8), let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data), rangeInfo.kind == "text-offset", rangeInfo.end > rangeInfo.start else { return nil } return rangeInfo } } /// 文本分页截断原因,描述为什么在当前位置分页 public struct RDEPUBHighlight: Codable, Equatable { /// 高亮唯一标识符 public var id: String /// 书籍标识符 public var bookIdentifier: String? /// 高亮发生的位置 public var location: RDEPUBLocation /// 高亮的文本内容 public var text: String /// 范围信息(Web EPUB 为 DOM Range JSON,文本 EPUB 为字符偏移 JSON) public var rangeInfo: String? /// 高亮样式(背景色高亮或下划线) public var style: RDEPUBHighlightStyle /// 高亮颜色(CSS 颜色值,如 "#F8E16C") public var color: String /// 用户备注 public var note: String? /// 创建时间 public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, location: RDEPUBLocation, text: String, rangeInfo: String? = nil, style: RDEPUBHighlightStyle = .highlight, color: String = "#F8E16C", note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.location = location self.text = text self.rangeInfo = rangeInfo?.nilIfEmpty self.style = style self.color = color self.note = note?.nilIfEmpty self.createdAt = createdAt } /// 是否包含用户备注 public var hasNote: Bool { note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } /// 将 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 case location case text case rangeInfo case style case color case note case createdAt } /// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier) location = try container.decode(RDEPUBLocation.self, forKey: .location) text = try container.decode(String.self, forKey: .text) rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style), let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) { style = decodedStyle } else { style = .highlight } color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C" note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } /// 自定义编码器 public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier) try container.encode(location, forKey: .location) try container.encode(text, forKey: .text) try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo) try container.encode(style, forKey: .style) try container.encode(color, forKey: .color) try container.encodeIfPresent(note, forKey: .note) try container.encode(createdAt, forKey: .createdAt) } public var annotation: RDEPUBAnnotation { RDEPUBAnnotation(highlight: self) } } /// 书签模型,记录用户标记的阅读位置 public struct RDEPUBBookmark: Codable, Equatable { /// 书签唯一标识符 public var id: String /// 书籍标识符 public var bookIdentifier: String? /// 书签位置 public var location: RDEPUBLocation /// 章节标题(用于书签列表展示) public var chapterTitle: String? /// 用户备注 public var note: String? /// 创建时间 public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, location: RDEPUBLocation, chapterTitle: String? = nil, note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.location = location self.chapterTitle = chapterTitle?.nilIfEmpty self.note = note?.nilIfEmpty self.createdAt = createdAt } /// 是否包含用户备注 public var hasNote: Bool { note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } public var annotation: RDEPUBAnnotation { RDEPUBAnnotation(bookmark: self) } } /// 统一标注模型,对标 WXRead 的 WRBookmark。 public struct RDEPUBAnnotation: Codable, Equatable { public var id: String public var bookIdentifier: String? public var kind: RDEPUBAnnotationKind public var location: RDEPUBLocation public var text: String? public var rangeInfo: String? public var color: String? public var chapterTitle: String? public var note: String? public var createdAt: Date public init( id: String = UUID().uuidString, bookIdentifier: String? = nil, kind: RDEPUBAnnotationKind, location: RDEPUBLocation, text: String? = nil, rangeInfo: String? = nil, color: String? = nil, chapterTitle: String? = nil, note: String? = nil, createdAt: Date = Date() ) { self.id = id self.bookIdentifier = bookIdentifier self.kind = kind self.location = location self.text = text?.nilIfEmpty self.rangeInfo = rangeInfo?.nilIfEmpty self.color = color?.nilIfEmpty self.chapterTitle = chapterTitle?.nilIfEmpty self.note = note?.nilIfEmpty self.createdAt = createdAt } public init(highlight: RDEPUBHighlight) { self.init( id: highlight.id, bookIdentifier: highlight.bookIdentifier, kind: highlight.style == .underline ? .underline : .highlight, location: highlight.location, text: highlight.text, rangeInfo: highlight.rangeInfo, color: highlight.color, chapterTitle: nil, note: highlight.note, createdAt: highlight.createdAt ) } public init(bookmark: RDEPUBBookmark) { self.init( id: bookmark.id, bookIdentifier: bookmark.bookIdentifier, kind: .bookmark, location: bookmark.location, text: nil, rangeInfo: nil, color: nil, chapterTitle: bookmark.chapterTitle, note: bookmark.note, createdAt: bookmark.createdAt ) } public var bookmark: RDEPUBBookmark? { guard kind == .bookmark else { return nil } return RDEPUBBookmark( id: id, bookIdentifier: bookIdentifier, location: location, chapterTitle: chapterTitle, note: note, createdAt: createdAt ) } public var highlight: RDEPUBHighlight? { guard kind == .highlight || kind == .underline, let text else { return nil } return RDEPUBHighlight( id: id, bookIdentifier: bookIdentifier, location: location, text: text, rangeInfo: rangeInfo, style: kind == .underline ? .underline : .highlight, color: color ?? "#F8E16C", note: note, createdAt: createdAt ) } } private extension String { var nilIfEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } }