- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
238 lines
8.7 KiB
Swift
238 lines
8.7 KiB
Swift
//
|
||
// RDEPUBModels.swift
|
||
// RDReaderView
|
||
//
|
||
// EPUBCore 层核心数据模型定义文件。
|
||
// 包含 EPUB 解析过程中所需的全部基础枚举和数据结构:
|
||
// 布局类型、阅读方向、页面分布、元数据、清单项、Spine 项、目录项及解析错误类型。
|
||
// 这些模型由 RDEPUBParser 解析后填充,并由 RDEPUBPublication 聚合对外暴露。
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// EPUB 版面布局类型
|
||
/// - reflowable: 可重排布局,内容根据屏幕尺寸和字号自动分页(小说类 EPUB)
|
||
/// - fixed: 固定版式布局,页面尺寸固定不变(漫画、绘本类 EPUB)
|
||
public enum RDEPUBLayout: String, Codable {
|
||
case reflowable
|
||
case fixed
|
||
}
|
||
|
||
/// 阅读配置文件类型,决定使用哪种渲染路径
|
||
/// - webInteractive: 可重排 + 含交互脚本,使用 WKWebView 渲染
|
||
/// - webFixedLayout: 固定版式 EPUB(漫画、绘本),使用 WKWebView 渲染
|
||
/// - textReflowable: 纯文本可重排 EPUB,使用 DTCoreText 渲染(性能更优)
|
||
public enum RDEPUBReadingProfile: String, Codable {
|
||
case webInteractive
|
||
case webFixedLayout
|
||
case textReflowable
|
||
}
|
||
|
||
/// 阅读方向(文字排版方向)
|
||
/// - ltr: 从左到右(英文、中文横排)
|
||
/// - rtl: 从右到左(日文竖排、阿拉伯文)
|
||
/// - auto: 根据语言自动判断
|
||
public enum RDEPUBReadingProgression: String, Codable {
|
||
case ltr
|
||
case rtl
|
||
case auto
|
||
}
|
||
|
||
/// 固定版式页面在 spread(双页展开)中的位置
|
||
/// - left: 左页
|
||
/// - right: 右页
|
||
/// - center: 居中(跨页或单页居中)
|
||
public enum RDEPUBPageSpread: String, Codable {
|
||
case left
|
||
case right
|
||
case center
|
||
}
|
||
|
||
/// EPUB 出版物元数据,对应 OPF 文档中 <metadata> 节点的内容
|
||
/// 包含书籍的基本信息:标识符、标题、作者、语言、版本、布局类型等
|
||
public struct RDEPUBMetadata: Codable, Equatable {
|
||
/// 书籍唯一标识符(对应 <dc:identifier>)
|
||
public var identifier: String?
|
||
/// 书名(对应 <dc:title>)
|
||
public var title: String
|
||
/// 作者(对应 <dc:creator>)
|
||
public var author: String?
|
||
/// 语言代码(对应 <dc:language>,如 "zh"、"en")
|
||
public var language: String?
|
||
/// EPUB 版本号(如 "2.0"、"3.0")
|
||
public var version: String?
|
||
/// 版面布局类型(reflowable 或 fixed)
|
||
public var layout: RDEPUBLayout
|
||
/// spread 模式设置(对应 <meta property="rendition:spread">)
|
||
public var spread: String?
|
||
/// 阅读方向(ltr / rtl / auto)
|
||
public var readingProgression: RDEPUBReadingProgression
|
||
|
||
public init(
|
||
identifier: String? = nil,
|
||
title: String = "",
|
||
author: String? = nil,
|
||
language: String? = nil,
|
||
version: String? = nil,
|
||
layout: RDEPUBLayout = .reflowable,
|
||
spread: String? = nil,
|
||
readingProgression: RDEPUBReadingProgression = .auto
|
||
) {
|
||
self.identifier = identifier
|
||
self.title = title
|
||
self.author = author
|
||
self.language = language
|
||
self.version = version
|
||
self.layout = layout
|
||
self.spread = spread
|
||
self.readingProgression = readingProgression
|
||
}
|
||
}
|
||
|
||
/// EPUB 清单项,对应 OPF 文档 <manifest> 中的每个 <item>
|
||
/// 描述一个资源文件的 ID、路径、MIME 类型和附加属性
|
||
public struct RDEPUBManifestItem: Codable, Equatable {
|
||
/// 清单项的唯一标识符(对应 <item id="...">)
|
||
public var id: String
|
||
/// 资源文件路径,相对于 OPF 文件所在目录
|
||
public var href: String
|
||
/// MIME 类型(如 "application/xhtml+xml"、"image/jpeg"、"text/css")
|
||
public var mediaType: String
|
||
/// 属性列表(如 "nav" 表示导航文档、"scripted" 表示含脚本、"cover-image" 表示封面)
|
||
public var properties: [String]
|
||
/// 回退项 ID(对应 <item fallback="...">)
|
||
public var fallback: String?
|
||
/// 媒体叠加文件 ID(EPUB 3 有声书功能)
|
||
public var mediaOverlay: String?
|
||
/// 资源标题(用于搜索结果展示等辅助用途)
|
||
public var title: String?
|
||
|
||
public init(
|
||
id: String,
|
||
href: String,
|
||
mediaType: String,
|
||
properties: [String] = [],
|
||
fallback: String? = nil,
|
||
mediaOverlay: String? = nil,
|
||
title: String? = nil
|
||
) {
|
||
self.id = id
|
||
self.href = href
|
||
self.mediaType = mediaType
|
||
self.properties = properties
|
||
self.fallback = fallback
|
||
self.mediaOverlay = mediaOverlay
|
||
self.title = title
|
||
}
|
||
|
||
/// 是否为 EPUB 3 导航文档(Nav Document)
|
||
/// 通过检查 properties 中是否包含 "nav" 标记判断
|
||
public var isNavigationDocument: Bool {
|
||
properties.contains("nav")
|
||
}
|
||
|
||
/// 是否为 NCX 目录文件(EPUB 2 格式)
|
||
/// 通过 MIME 类型 "application/x-dtbncx+xml" 判断
|
||
public var isNCX: Bool {
|
||
mediaType == "application/x-dtbncx+xml"
|
||
}
|
||
}
|
||
|
||
/// EPUB Spine 项,对应 OPF 文档 <spine> 中的每个 <itemref>
|
||
/// Spine 定义了阅读器中章节内容的线性阅读顺序
|
||
public struct RDEPUBSpineItem: Codable, Equatable {
|
||
/// 对应 manifest 中的 item id(用于关联资源)
|
||
public var idref: String
|
||
/// 标准化后的资源路径(相对于 OPF 文件所在目录)
|
||
public var href: String
|
||
/// MIME 类型
|
||
public var mediaType: String
|
||
/// 章节标题(从目录或 manifest 推断)
|
||
public var title: String
|
||
/// 是否为线性内容("no" 表示补充材料,不影响主线阅读进度)
|
||
public var linear: Bool
|
||
/// 属性列表(如 rendition:layout-* 用于单页布局覆盖)
|
||
public var properties: [String]
|
||
/// 页面在 spread 中的位置偏好(左页/右页/居中)
|
||
public var pageSpread: RDEPUBPageSpread?
|
||
/// 单页布局覆盖(显式属性可覆盖出版级别的 layout 设置)
|
||
public var layout: RDEPUBLayout?
|
||
|
||
public init(
|
||
idref: String,
|
||
href: String,
|
||
mediaType: String,
|
||
title: String,
|
||
linear: Bool = true,
|
||
properties: [String] = [],
|
||
pageSpread: RDEPUBPageSpread? = nil,
|
||
layout: RDEPUBLayout? = nil
|
||
) {
|
||
self.idref = idref
|
||
self.href = href
|
||
self.mediaType = mediaType
|
||
self.title = title
|
||
self.linear = linear
|
||
self.properties = properties
|
||
self.pageSpread = pageSpread
|
||
self.layout = layout
|
||
}
|
||
}
|
||
|
||
/// EPUB 目录项,支持树形层级结构
|
||
/// 来源于 NCX 文件(EPUB 2)或 Nav Document(EPUB 3)的解析结果
|
||
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||
/// 目录项标题(显示在目录面板中)
|
||
public var title: String
|
||
/// 目标资源路径(含可能的 fragment 锚点)
|
||
public var href: String
|
||
/// 子目录项列表(递归结构,支持多级目录)
|
||
public var children: [EPUBTableOfContentsItem]
|
||
|
||
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
|
||
self.title = title
|
||
self.href = href
|
||
self.children = children
|
||
}
|
||
}
|
||
|
||
/// EPUB 解析器错误类型,涵盖从 ZIP 解压到 OPF 解析全链路的异常情况
|
||
public enum RDEPUBParserError: LocalizedError {
|
||
/// ZIP 文件无法打开(文件损坏或格式不支持)
|
||
case archiveOpenFailed(URL)
|
||
/// 缺少 META-INF/container.xml 文件(EPUB 格式不合规)
|
||
case missingContainerXML
|
||
/// container.xml 中未找到 <rootfile> 元素
|
||
case missingRootFile
|
||
/// OPF 根文件路径无效(文件不存在)
|
||
case invalidRootFilePath(String)
|
||
/// XML 解析失败(格式错误或编码问题)
|
||
case invalidXML(URL)
|
||
/// spine 中引用的 manifest 项不存在(idref 不匹配)
|
||
case missingManifestItem(idref: String)
|
||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||
case emptySpine
|
||
/// 归档条目路径包含路径穿越或非法字符
|
||
case invalidArchiveEntryPath(String)
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .archiveOpenFailed(let url):
|
||
return "无法打开 EPUB 压缩包: \(url.lastPathComponent)"
|
||
case .missingContainerXML:
|
||
return "EPUB 缺少 META-INF/container.xml"
|
||
case .missingRootFile:
|
||
return "container.xml 中未找到 rootfile"
|
||
case .invalidRootFilePath(let path):
|
||
return "OPF 路径无效: \(path)"
|
||
case .invalidXML(let url):
|
||
return "XML 解析失败: \(url.lastPathComponent)"
|
||
case .missingManifestItem(let idref):
|
||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||
case .emptySpine:
|
||
return "OPF spine 为空"
|
||
case .invalidArchiveEntryPath(let path):
|
||
return "归档条目路径非法: \(path)"
|
||
}
|
||
}
|
||
} |