Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift
T

368 lines
12 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.
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 的 UIColorForHighlightColor35% 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
}
}