720 lines
24 KiB
Swift
720 lines
24 KiB
Swift
//
|
||
// 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 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 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
|
||
}
|
||
}
|