refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
This commit is contained in:
parent
5af90c1148
commit
54798ba578
@ -12,7 +12,7 @@ Pod::Spec.new do |s|
|
||||
s.license = "MIT"
|
||||
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
|
||||
s.resource_bundles = {
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/Resources/**/*']
|
||||
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
|
||||
}
|
||||
s.dependency 'ZIPFoundation', '~> 0.9'
|
||||
s.dependency 'DTCoreText'
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
// RDEPUBAssetRepository.swift
|
||||
// EPUBCore 静态资源仓库
|
||||
// 负责从资源包中加载 EPUB 渲染所需的 JS 桥接脚本和固定版式 HTML 模板,
|
||||
// 支持模板变量替换({{token}} 占位符)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
|
||||
enum RDEPUBAsset: String {
|
||||
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
|
||||
case bridgeScript = "epub-bridge"
|
||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
||||
|
||||
/// 对应的文件扩展名
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .bridgeScript:
|
||||
@ -14,7 +23,13 @@ enum RDEPUBAsset: String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态资源加载器,负责从资源包中读取模板文件并执行变量替换
|
||||
enum RDEPUBAssetRepository {
|
||||
/// 加载指定资源文件的内容,并将 {{key}} 占位符替换为对应的值
|
||||
/// - Parameters:
|
||||
/// - asset: 要加载的资源类型
|
||||
/// - replacements: 模板变量字典,key 为占位符名称,value 为替换值
|
||||
/// - Returns: 替换后的文件内容字符串
|
||||
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
|
||||
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
|
||||
var content = try? String(contentsOf: url, encoding: .utf8) else {
|
||||
@ -28,6 +43,7 @@ enum RDEPUBAssetRepository {
|
||||
return content
|
||||
}
|
||||
|
||||
/// 获取资源所在的 Bundle(优先使用已解析的子 bundle,兜底到宿主 bundle)
|
||||
private static var resourceBundle: Bundle {
|
||||
if let bundle = resolvedBundle {
|
||||
return bundle
|
||||
@ -35,6 +51,7 @@ enum RDEPUBAssetRepository {
|
||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
||||
}
|
||||
|
||||
/// 延迟解析 RDReaderViewAssets.bundle 的位置,在宿主 bundle 和所有 framework 中查找
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
@ -47,4 +64,5 @@ enum RDEPUBAssetRepository {
|
||||
}()
|
||||
}
|
||||
|
||||
/// 用于定位当前模块 Bundle 的标记类
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
@ -1,12 +1,25 @@
|
||||
// RDEPUBFixedLayoutTemplate.swift
|
||||
// 固定版式(Fixed Layout)HTML 模板生成器
|
||||
// 根据渲染请求和 Publication 信息,将 iframe 拼装到 HTML 模板中,
|
||||
// 生成适配视口尺寸和内边距的固定版式页面 HTML。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 固定版式模板生成器,负责将 spine 资源渲染为 iframe 嵌套的 HTML 页面
|
||||
enum RDEPUBFixedLayoutTemplate {
|
||||
/// 根据渲染请求和 Publication 生成固定版式页面的完整 HTML
|
||||
/// - Parameters:
|
||||
/// - request: 固定版式渲染请求(含 spread、视口尺寸、内边距、适配模式等)
|
||||
/// - publication: EPUB 出版物,用于解析资源 URL
|
||||
/// - Returns: 完整的 HTML 字符串
|
||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
||||
// 遍历 spread 中的每个资源,生成对应的 iframe pane HTML
|
||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 根据 spread 中的资源数量判断页面类型:单页/左页/右页/居中
|
||||
let pageType: String
|
||||
if request.spread.resources.count == 1 {
|
||||
pageType = "single"
|
||||
@ -23,6 +36,7 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
"""
|
||||
}.joined()
|
||||
|
||||
// 计算视口区域(去除内边距后的可用区域)
|
||||
let background = request.backgroundColorCSS ?? "#FFFFFF"
|
||||
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
|
||||
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
|
||||
@ -30,6 +44,7 @@ enum RDEPUBFixedLayoutTemplate {
|
||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
||||
// 从资源仓库加载 HTML 模板并替换占位符
|
||||
return RDEPUBAssetRepository.string(
|
||||
for: .fixedLayoutTemplate,
|
||||
replacements: [
|
||||
|
||||
@ -1,19 +1,35 @@
|
||||
// RDEPUBJavaScriptBridge.swift
|
||||
// 原生与 WebView 之间的 JavaScript 桥接层
|
||||
// 定义所有 JS 消息类型(progression/selection/link/error 等),
|
||||
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
|
||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||
/// 阅读进度变化事件
|
||||
case progressionChanged = "ssReaderProgressionChanged"
|
||||
/// 文本选择变化事件
|
||||
case selectionChanged = "ssReaderSelectionChanged"
|
||||
/// 内部链接点击事件(ss-reader:// 协议)
|
||||
case internalLink = "ssReaderInternalLink"
|
||||
/// 外部链接点击事件(http/https/mailto/tel)
|
||||
case externalLink = "ssReaderExternalLink"
|
||||
/// JavaScript 运行时错误事件
|
||||
case javaScriptError = "ssReaderJSError"
|
||||
/// 固定版式渲染就绪事件
|
||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||
}
|
||||
|
||||
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
|
||||
enum RDEPUBJavaScriptBridge {
|
||||
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
|
||||
static var messageNames: [String] {
|
||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
||||
}
|
||||
|
||||
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
|
||||
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
|
||||
static var userScript: String {
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
@ -28,6 +44,10 @@ enum RDEPUBJavaScriptBridge {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成重排模式下的分页渲染脚本
|
||||
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
|
||||
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
|
||||
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
|
||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
||||
let pageStride = max(1, request.presentation.viewportSize.width)
|
||||
@ -52,6 +72,11 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成搜索高亮脚本
|
||||
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
|
||||
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
|
||||
/// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等)
|
||||
/// - Returns: 搜索高亮脚本字符串
|
||||
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
@ -213,12 +238,14 @@ enum RDEPUBJavaScriptBridge {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
|
||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
}
|
||||
|
||||
/// 将高亮数组转换为 JS 可用的字典数组
|
||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
||||
highlights.compactMap { highlight in
|
||||
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
|
||||
@ -231,6 +258,7 @@ enum RDEPUBJavaScriptBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将目标位置转换为 JS 可用的字典(含 progression、fragment)
|
||||
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
|
||||
guard let location else { return nil }
|
||||
return [
|
||||
@ -240,6 +268,7 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将搜索展示信息转换为 JS 可用的字典
|
||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
||||
guard let presentation else { return nil }
|
||||
return [
|
||||
@ -254,6 +283,7 @@ enum RDEPUBJavaScriptBridge {
|
||||
]
|
||||
}
|
||||
|
||||
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
|
||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
||||
guard let object else { return fallback }
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
|
||||
@ -1,36 +1,71 @@
|
||||
//
|
||||
// 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(
|
||||
@ -54,13 +89,22 @@ public struct RDEPUBMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@ -81,23 +125,37 @@ public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
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(
|
||||
@ -121,9 +179,14 @@ public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] = []) {
|
||||
@ -133,13 +196,21 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
public var errorDescription: String? {
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
// RDEPUBNavigatorLayoutContext.swift
|
||||
// 导航器布局上下文
|
||||
// 封装阅读器容器的布局参数:容器尺寸、每屏页数、安全区域、设备类型、
|
||||
// 重排内容内边距等,供 Paginator 和渲染层使用。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 导航器布局上下文,描述阅读器容器的布局参数
|
||||
/// 在 Paginator 计算分页和 WebView 渲染时作为核心输入
|
||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
/// 容器视图的总尺寸
|
||||
public var containerSize: CGSize
|
||||
/// 每屏显示的页数(横屏双页模式为 2)
|
||||
public var pagesPerScreen: Int
|
||||
/// 安全区域内边距(刘海屏、Home Indicator 等)
|
||||
public var safeAreaInsets: UIEdgeInsets
|
||||
/// 用户界面设备类型(phone/pad)
|
||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
||||
/// 重排模式下的内容内边距(上下左右留白)
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public init(
|
||||
@ -21,6 +33,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
}
|
||||
|
||||
/// 计算单页视口尺寸(双页模式下宽度除以 2)
|
||||
public var viewportSize: CGSize {
|
||||
let width: CGFloat
|
||||
if pagesPerScreen > 1 {
|
||||
@ -31,6 +44,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
/// 固定版式模式下的内容内边距(仅手机端使用安全区域,Pad 端归零;左右取较大值对称)
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
//
|
||||
// RDEPUBNavigatorState.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层导航状态机定义文件。
|
||||
// 定义了阅读器导航器的状态枚举,状态流转遵循:
|
||||
// initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
// 由 RDEPUBReadingSession 管理状态跃迁,控制分页快照的应用时机。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 导航状态枚举,描述阅读器的当前操作状态
|
||||
/// 状态机流转:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
/// 初始化状态,刚打开书籍,等待初始恢复位置
|
||||
case initializing
|
||||
/// 加载状态,正在生成首轮分页模型
|
||||
case loading
|
||||
/// 空闲状态,显示稳定,可接受用户操作和快照应用
|
||||
case idle
|
||||
/// 跳转状态,正在执行目录/内部链接跳转
|
||||
case jumping
|
||||
/// 翻页状态,用户正在翻页操作中
|
||||
case moving
|
||||
/// 重排状态,正在重新分页(字号/横竖屏变化触发)
|
||||
case repaginating
|
||||
|
||||
/// 是否处于可应用分页快照的稳定状态
|
||||
/// 只有 idle 状态才允许消费 staged snapshot
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
|
||||
@ -1,25 +1,45 @@
|
||||
// RDEPUBPaginator.swift
|
||||
// EPUB 离屏分页计算器
|
||||
// 使用隐藏的 WKWebView 加载每个 spine 项,通过 JS 测量 scrollWidth
|
||||
// 计算每章的页数。采用多轮延迟测量(0ms/80ms/180ms)确保布局稳定。
|
||||
// 固定版式(Fixed Layout)直接返回 1 页。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// EPUB 分页计算器,通过离屏 WebView 测量每个 spine 项的分页数
|
||||
public final class RDEPUBPaginator: NSObject {
|
||||
/// 当前解析器引用
|
||||
private var parser: RDEPUBParser?
|
||||
/// 承载 WebView 的父视图(弱引用)
|
||||
private weak var hostingView: UIView?
|
||||
/// 当前展示样式(视口尺寸、内边距、字号、行高)
|
||||
private var presentation = RDEPUBPresentationStyle(
|
||||
viewportSize: .zero,
|
||||
contentInsets: .zero,
|
||||
fontSize: 16,
|
||||
lineHeightMultiple: 1.5
|
||||
)
|
||||
/// 全量分页完成回调(返回每个 spine 项的页数数组)
|
||||
private var completion: (([Int]) -> Void)?
|
||||
/// 单个 spine 项分页完成回调
|
||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
||||
/// 各 spine 项的页数结果
|
||||
private var pageCounts: [Int] = []
|
||||
/// 待测量的 spine 项索引列表
|
||||
private var measurementIndices: [Int] = []
|
||||
/// 当前测量偏移量(在 measurementIndices 中的位置)
|
||||
private var currentMeasurementOffset = 0
|
||||
/// 当前 spine 项的待定页数(多轮测量取最大值)
|
||||
private var pendingMeasurementValue = 1
|
||||
/// 当前测量轮次(0/1/2 对应延迟 0ms/80ms/180ms)
|
||||
private var measurementPass = 0
|
||||
/// 当前测量会话 ID,用于防并发和取消过期任务
|
||||
private var activeSessionID = UUID()
|
||||
/// 调试日志作用域标识
|
||||
private let debugScope = "PaginatorWebView"
|
||||
|
||||
/// 用于分页测量的隐藏 WebView(非持久化数据存储,禁止用户交互)
|
||||
private lazy var webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
@ -52,6 +72,12 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 计算所有 spine 项的分页数
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器
|
||||
/// - hostingView: 承载 WebView 的父视图
|
||||
/// - presentation: 展示样式(视口、内边距、字号、行高)
|
||||
/// - completion: 完成回调,返回每个 spine 项的页数数组
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
@ -82,6 +108,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算所有 spine 项的分页数
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
@ -104,6 +131,13 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 仅计算单个 spine 项的分页数
|
||||
/// - Parameters:
|
||||
/// - parser: EPUB 解析器
|
||||
/// - spineIndex: 要测量的 spine 项索引
|
||||
/// - hostingView: 承载 WebView 的父视图
|
||||
/// - presentation: 展示样式
|
||||
/// - completion: 完成回调,返回该 spine 项的页数
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -135,6 +169,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
/// 便捷方法:使用独立参数计算单个 spine 项的分页数
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -159,6 +194,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 静态方法:将分页 CSS 注入到 HTML 字符串中(用于预渲染场景)
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
viewportSize: CGSize,
|
||||
@ -181,6 +217,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 测量下一个 spine 项:加载文件到 WebView,跳过不可渲染或文件缺失的项
|
||||
private func measureNextSpineItem() {
|
||||
guard let parser else {
|
||||
finishIfNeeded()
|
||||
@ -218,10 +255,12 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
||||
}
|
||||
|
||||
/// 获取当前正在测量的 spine 项索引
|
||||
private func currentSpineIndexForMeasurement() -> Int? {
|
||||
measurementIndices[safe: currentMeasurementOffset]
|
||||
}
|
||||
|
||||
/// 检查是否所有 spine 项都已测量完毕,触发对应的完成回调
|
||||
private func finishIfNeeded() {
|
||||
if let singlePageCountCompletion {
|
||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
||||
@ -236,6 +275,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
cleanupMeasurementState()
|
||||
}
|
||||
|
||||
/// 清理测量状态,释放 WebView 和所有引用
|
||||
private func cleanupMeasurementState() {
|
||||
parser = nil
|
||||
hostingView = nil
|
||||
@ -251,11 +291,13 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
/// 判断 spine 项是否可渲染(仅 HTML/XHTML/XML 类型可分页)
|
||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
|
||||
@ -278,6 +320,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行当前文档的测量:通过 JS 获取 scrollWidth 并计算页数
|
||||
private func measureCurrentDocument(sessionID: UUID) {
|
||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
||||
@ -332,6 +375,7 @@ extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Array 安全下标扩展,越界时返回 nil 而非崩溃
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
// RDEPUBParser+Archive.swift
|
||||
// EPUB 归档解压与 container.xml 解析
|
||||
// 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/),
|
||||
// 并解析 META-INF/container.xml 获取 OPF 文件路径。
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 解析 container.xml,提取 OPF(Package Document)的相对路径
|
||||
/// - Parameter containerURL: container.xml 的文件路径
|
||||
/// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf")
|
||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||||
throw RDEPUBParserError.invalidXML(containerURL)
|
||||
@ -22,6 +30,10 @@ extension RDEPUBParser {
|
||||
return rootFilePath
|
||||
}
|
||||
|
||||
/// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过)
|
||||
/// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突
|
||||
/// - Parameter epubURL: EPUB 文件路径
|
||||
/// - Returns: 解压目标目录 URL
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
@ -52,6 +64,8 @@ extension RDEPUBParser {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
/// 计算 EPUB 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
@ -65,12 +79,14 @@ extension RDEPUBParser {
|
||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||||
}
|
||||
|
||||
/// 重置解析器状态(清除解压路径和出版物数据)
|
||||
func reset() {
|
||||
extractionRootURL = nil
|
||||
opfURL = nil
|
||||
resetPublicationState()
|
||||
}
|
||||
|
||||
/// 重置出版物相关状态(metadata、manifest、spine、目录)
|
||||
func resetPublicationState() {
|
||||
metadata = RDEPUBMetadata()
|
||||
manifest = [:]
|
||||
@ -79,7 +95,9 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// container.xml 的 SAX 解析代理,提取 <rootfile> 元素的 full-path 属性
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
/// 解析得到的 OPF 文件相对路径
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
func parser(
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
// RDEPUBParser+Package.swift
|
||||
// OPF(Package Document)解析与 Spine 构建
|
||||
// 包含 OPFPackageParserDelegate(SAX 解析 metadata/manifest/spine),
|
||||
// 从解析结果构建 RDEPUBSpineItem 数组,处理 rendition:layout、page-spread 等属性。
|
||||
// 同时定义 XMLName、XMLSection、XMLParserContext 等 XML 解析辅助工具。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 从 OPF 解析结果构建 spine 阅读顺序数组
|
||||
/// 逐项匹配 manifest,解析 href、布局模式(fixed/reflowable)、page-spread 等属性
|
||||
/// - Parameters:
|
||||
/// - packageDocument: OPF 解析结果(含 metadata、manifest、spineReferences)
|
||||
/// - opfURL: OPF 文件路径
|
||||
/// - Returns: spine 项数组
|
||||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
let publicationLayout = packageDocument.metadata.layout
|
||||
@ -50,22 +62,33 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 包文档的解析结果,包含 metadata、manifest、spine 引用及 NCX/nav 项
|
||||
struct OPFPackageDocument {
|
||||
var metadata: RDEPUBMetadata
|
||||
var manifest: [RDEPUBManifestItem]
|
||||
var manifestByID: [String: RDEPUBManifestItem]
|
||||
var spineReferences: [OPFSpineReference]
|
||||
/// NCX 目录文件的 manifest 项(EPUB 2)
|
||||
var ncxItem: RDEPUBManifestItem?
|
||||
/// Navigation Document 的 manifest 项(EPUB 3)
|
||||
var navigationItem: RDEPUBManifestItem?
|
||||
}
|
||||
|
||||
/// OPF 中 spine/itemref 元素的解析结果
|
||||
struct OPFSpineReference {
|
||||
/// 对应 manifest 中的 item id
|
||||
var idref: String
|
||||
/// 是否为线性阅读项("no" 表示辅助内容)
|
||||
var linear: Bool
|
||||
/// 属性列表(如 rendition:layout-pre-paginated、page-spread-left 等)
|
||||
var properties: [String]
|
||||
/// 页面展开方向(左/右/居中)
|
||||
var pageSpread: RDEPUBPageSpread?
|
||||
}
|
||||
|
||||
/// OPF 文档的 SAX 解析代理
|
||||
/// 解析 metadata(identifier/title/creator/language/rendition:layout 等)、
|
||||
/// manifest(item 元素)、spine(itemref 元素)三个主要区域
|
||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private var metadata = RDEPUBMetadata()
|
||||
private var manifestItems: [RDEPUBManifestItem] = []
|
||||
@ -79,6 +102,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private var currentMetaRefinesID: String?
|
||||
private var ncxID: String?
|
||||
|
||||
/// 将解析中间结果组装为 OPFPackageDocument 结构体
|
||||
func packageDocument() -> OPFPackageDocument {
|
||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||||
metadata.identifier = fallbackIdentifier
|
||||
@ -227,6 +251,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
xmlContext.endElement()
|
||||
}
|
||||
|
||||
/// 当前解析所在的 OPF 区域(metadata/manifest/spine/other)
|
||||
private var currentSection: XMLSection {
|
||||
if xmlContext.containsElement(named: "manifest") {
|
||||
return .manifest
|
||||
@ -240,6 +265,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return .other
|
||||
}
|
||||
|
||||
/// 处理 <meta> 元素的文本内容,提取 rendition:layout、rendition:spread、identifier 等
|
||||
private func applyMetaValue(_ value: String) {
|
||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
||||
@ -264,6 +290,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从属性字典中提取 page-spread 信息(支持 properties 和 page-spread 两种写法)
|
||||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
||||
if let properties = attributes["properties"]?.lowercased() {
|
||||
if properties.contains("page-spread-left") {
|
||||
@ -293,11 +320,13 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 rendition:layout 原始值转换为布局枚举
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
}
|
||||
|
||||
/// OPF 文档的区域枚举,用于区分当前解析位置
|
||||
enum XMLSection {
|
||||
case metadata
|
||||
case manifest
|
||||
@ -305,11 +334,14 @@ enum XMLSection {
|
||||
case other
|
||||
}
|
||||
|
||||
/// XML 名称解析工具:提取本地名、分词、提取 refines ID
|
||||
enum XMLName {
|
||||
/// 从带命名空间前缀的名称中提取本地名(如 "dc:title" -> "title")
|
||||
static func localName(from rawName: String) -> String {
|
||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||||
}
|
||||
|
||||
/// 将空格分隔的属性值拆分为 token 数组
|
||||
static func tokenize(_ rawValue: String?) -> [String] {
|
||||
guard let rawValue else { return [] }
|
||||
return rawValue
|
||||
@ -317,6 +349,7 @@ enum XMLName {
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
/// 从 refines 属性值中提取 ID(去掉 "#" 前缀)
|
||||
static func refinedID(from rawValue: String) -> String? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("#") else {
|
||||
@ -326,10 +359,14 @@ enum XMLName {
|
||||
}
|
||||
}
|
||||
|
||||
/// XML 解析上下文,维护元素栈和当前文本内容
|
||||
final class XMLParserContext {
|
||||
/// 元素名称栈,用于追踪嵌套层级
|
||||
private var elementStack: [String] = []
|
||||
/// 当前元素累积的文本内容
|
||||
private var currentCharacters = ""
|
||||
|
||||
/// 去除空白后的当前文本
|
||||
var trimmedCharacters: String {
|
||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
// RDEPUBParser+ReadingProfile.swift
|
||||
// EPUB 阅读配置文件判断
|
||||
// 根据 metadata 的 layout 属性和 manifest/spine 内容,
|
||||
// 将出版物分类为 webFixedLayout(固定版式)、webInteractive(含脚本/多媒体)或 textReflowable(纯文本重排)。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 判断出版物的阅读配置文件
|
||||
/// 优先判断固定版式,其次检测交互内容(脚本/iframe/视频等),默认为文本重排
|
||||
public func readingProfile() -> RDEPUBReadingProfile {
|
||||
if metadata.layout == .fixed {
|
||||
return .webFixedLayout
|
||||
@ -8,6 +15,9 @@ extension RDEPUBParser {
|
||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
||||
}
|
||||
|
||||
/// 检测出版物是否包含交互内容
|
||||
/// 通过 manifest 中的脚本媒体类型和 "scripted" 属性,
|
||||
/// 以及扫描 HTML 中的 script/iframe/video/audio/canvas/svg/form 等标签
|
||||
public func hasInteractiveContent() -> Bool {
|
||||
let interactiveMediaTypes = [
|
||||
"application/javascript",
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
// RDEPUBParser+Resources.swift
|
||||
// EPUB 资源查询与 URL 解析
|
||||
// 提供按 ID、href 查询 manifest 项,spine 索引与 href 互转,
|
||||
// 文件路径与 ss-reader:// 协议 URL 互转,封面图提取,HTML 内容读取等能力。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 按 ID 查询 manifest 项
|
||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
||||
manifest[id]
|
||||
}
|
||||
|
||||
/// 按 href 查询 manifest 项(忽略 fragment)
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let target = href.components(separatedBy: "#").first ?? href
|
||||
return manifest.values.first { item in
|
||||
@ -13,11 +20,13 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else { return nil }
|
||||
return spine[index].href
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL,并校验是否在解压根目录内(防路径遍历攻击)
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
guard let opfDirectoryURL else { return nil }
|
||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
||||
@ -32,6 +41,7 @@ extension RDEPUBParser {
|
||||
return resolvedURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议的 URL(用于 WebView 加载资源)
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !normalizedPath.isEmpty else { return nil }
|
||||
@ -45,6 +55,7 @@ extension RDEPUBParser {
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议的 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
||||
@ -54,6 +65,7 @@ extension RDEPUBParser {
|
||||
return fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 提取封面图片(优先查找含 "cover-image" 属性或 ID 含 "cover" 的 manifest 项)
|
||||
public func coverImage() -> UIImage? {
|
||||
let coverCandidates = manifest.values.filter { item in
|
||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
||||
@ -67,6 +79,7 @@ extension RDEPUBParser {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 通过 spine 索引读取 HTML 内容
|
||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else {
|
||||
return nil
|
||||
@ -74,6 +87,7 @@ extension RDEPUBParser {
|
||||
return htmlString(forRelativePath: spine[index].href)
|
||||
}
|
||||
|
||||
/// 通过相对路径读取 HTML 内容
|
||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
@ -81,6 +95,7 @@ extension RDEPUBParser {
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
/// 将 href 相对于目录 URL 解析为标准化的相对路径
|
||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
||||
return href
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
// RDEPUBParser+TOC.swift
|
||||
// EPUB 目录解析
|
||||
// 支持两种目录格式:NCX(EPUB 2)和 Navigation Document(EPUB 3),
|
||||
// 优先使用 NCX,其次 Nav Document,最终回退到 spine 列表。
|
||||
// 内部使用 TOCTreeBuilder 构建树状目录结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
/// 从 OPF 解析结果中解析目录
|
||||
/// 优先尝试 NCX,再尝试 Nav Document,两者都失败则回退到 spine 列表
|
||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
|
||||
@ -23,6 +31,7 @@ extension RDEPUBParser {
|
||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
||||
}
|
||||
|
||||
/// 解析 NCX(EPUB 2)目录文件
|
||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
||||
@ -40,6 +49,7 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 解析 Navigation Document(EPUB 3)目录文件
|
||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
||||
let parser = XMLParser(contentsOf: navURL) else {
|
||||
@ -57,6 +67,7 @@ extension RDEPUBParser {
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
/// 标准化目录 href(拆分路径和 fragment,对路径部分做相对路径解析)
|
||||
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
|
||||
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = components.first.map(String.init) ?? href
|
||||
@ -80,11 +91,13 @@ extension RDEPUBParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树节点,存储标题、href 和子节点,最终转换为 EPUBTableOfContentsItem
|
||||
private final class TOCNode {
|
||||
var title = ""
|
||||
var href: String?
|
||||
var children: [TOCNode] = []
|
||||
|
||||
/// 将节点及其子节点递归转换为目录项
|
||||
func asItem() -> EPUBTableOfContentsItem? {
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let href, !href.isEmpty else {
|
||||
@ -98,10 +111,14 @@ private final class TOCNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录树构建器:通过 beginNode/endNode 操作维护节点栈,构建嵌套目录结构
|
||||
private final class TOCTreeBuilder {
|
||||
/// 当前嵌套路径上的节点栈
|
||||
private var nodeStack: [TOCNode] = []
|
||||
/// 顶层根节点列表
|
||||
private var rootNodes: [TOCNode] = []
|
||||
|
||||
/// 获取构建完成的目录项列表
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
rootNodes.compactMap { $0.asItem() }
|
||||
}
|
||||
@ -134,6 +151,7 @@ private final class TOCTreeBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NCX 文档的 SAX 解析代理,解析 <navPoint>、<content>、<text> 元素
|
||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
@ -198,6 +216,7 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation Document 的 SAX 解析代理,解析含 epub:type="toc" 的 <nav> 元素
|
||||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
@ -284,6 +303,7 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 <nav> 元素是否为 TOC 导航(检查 epub:type、type、role 属性)
|
||||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||||
return true
|
||||
|
||||
@ -1,23 +1,41 @@
|
||||
// RDEPUBParser.swift
|
||||
// EPUB 解析器入口
|
||||
// 解析链路:epubURL → ZIP 解压 → container.xml → OPF → spine/TOC
|
||||
// 是 EPUBCore Publication 层的核心,解析完成后通过 makePublication() 生成 RDEPUBPublication。
|
||||
// 具体解析逻辑分散在 +Archive、+Package、+TOC、+Resources、+ReadingProfile 扩展中。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 解析器,执行从 epub 文件到出版物数据的完整解析流程
|
||||
/// 主链路:epubURL → extractArchiveIfNeeded → container.xml → OPF → metadata/manifest/spine/TOC
|
||||
public final class RDEPUBParser {
|
||||
/// 出版物元数据(标题、作者、语言、布局模式等)
|
||||
public internal(set) var metadata = RDEPUBMetadata()
|
||||
/// 资源清单字典(key 为 manifest item ID)
|
||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
||||
/// 阅读顺序列表(按 spine 排列的资源项)
|
||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
||||
/// 目录树(从 NCX 或 Nav Document 解析得到)
|
||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
||||
/// EPUB 解压后的根目录路径
|
||||
public internal(set) var extractionRootURL: URL?
|
||||
/// OPF 文件的完整路径
|
||||
public internal(set) var opfURL: URL?
|
||||
|
||||
/// OPF 文件所在目录的 URL(用于解析相对路径)
|
||||
public var opfDirectoryURL: URL? {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 基于当前解析结果创建 RDEPUBPublication(门面对象)
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
|
||||
/// 完整解析 EPUB 文件:解压 → container.xml → OPF
|
||||
/// - Parameter epubURL: EPUB 文件的本地路径
|
||||
public func parse(epubURL: URL) throws {
|
||||
reset()
|
||||
|
||||
@ -38,6 +56,8 @@ public final class RDEPUBParser {
|
||||
try parseOPF(at: packageURL)
|
||||
}
|
||||
|
||||
/// 解析 OPF 文件:metadata/manifest/spine/TOC
|
||||
/// - Parameter opfURL: OPF 文件路径
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
resetPublicationState()
|
||||
|
||||
@ -61,10 +81,12 @@ public final class RDEPUBParser {
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
|
||||
/// 获取已解析的目录
|
||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
||||
tableOfContents
|
||||
}
|
||||
|
||||
/// 解析指定的 Navigation Document 文件
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
// RDEPUBPreferences.swift
|
||||
// EPUB 阅读偏好设置
|
||||
// 封装字号、行高、内容内边距、主题颜色、固定版式适配模式等展示参数,
|
||||
// 并提供从偏好设置生成渲染请求和展示样式的便捷方法。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读偏好设置,包含所有影响渲染效果的展示参数
|
||||
public struct RDEPUBPreferences: Equatable {
|
||||
/// 字体大小(pt)
|
||||
public var fontSize: CGFloat
|
||||
/// 行高倍数(如 1.5 表示 1.5 倍行高)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 重排模式下的内容内边距
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定版式下的内容内边距
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值)
|
||||
public var themeTextColor: String?
|
||||
/// 固定版式背景色
|
||||
public var fixedBackgroundColor: String?
|
||||
/// 固定版式适配模式(page/width/height/contain/cover)
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定版式双页展开模式(never/always/automatic)
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
|
||||
public init(
|
||||
@ -33,6 +48,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
}
|
||||
|
||||
/// 从偏好设置生成重排模式的展示样式
|
||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
||||
RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
@ -44,6 +60,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面和偏好设置生成对应的渲染请求(自动区分固定版式和重排)
|
||||
public func renderRequest(
|
||||
for page: EPUBPage,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
// RDEPUBPublication.swift
|
||||
// EPUB 出版物门面(Facade)
|
||||
// 对 RDEPUBParser 的高层封装,提供 metadata、spine、TOC 等只读访问,
|
||||
// 以及固定版式双页展开判断和 spread 生成等业务逻辑。
|
||||
// 是 EPUBCore Publication 层对外暴露的主要接口。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 出版物门面对象,封装解析结果并提供高层访问接口
|
||||
public final class RDEPUBPublication {
|
||||
/// 底层解析器(持有解析数据)
|
||||
public let parser: RDEPUBParser
|
||||
/// 资源解析器(URL 转换和路径规范化)
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
@ -9,38 +18,48 @@ public final class RDEPUBPublication {
|
||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
||||
}
|
||||
|
||||
/// 元数据代理
|
||||
public var metadata: RDEPUBMetadata {
|
||||
parser.metadata
|
||||
}
|
||||
|
||||
/// 资源清单代理
|
||||
public var manifest: [String: RDEPUBManifestItem] {
|
||||
parser.manifest
|
||||
}
|
||||
|
||||
/// 阅读顺序代理
|
||||
public var spine: [RDEPUBSpineItem] {
|
||||
parser.spine
|
||||
}
|
||||
|
||||
/// 目录代理
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
parser.tableOfContents
|
||||
}
|
||||
|
||||
/// 布局模式(fixed/reflowable)
|
||||
public var layout: RDEPUBLayout {
|
||||
metadata.layout
|
||||
}
|
||||
|
||||
/// 阅读配置文件(webFixedLayout/webInteractive/textReflowable)
|
||||
public var readingProfile: RDEPUBReadingProfile {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
/// 阅读方向(ltr/rtl/auto)
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
/// 书籍唯一标识符
|
||||
public var bookIdentifier: String? {
|
||||
metadata.identifier
|
||||
}
|
||||
|
||||
/// 判断固定版式是否启用双页展开模式
|
||||
/// 综合考虑 metadata.spread、偏好设置和视口方向
|
||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
||||
guard layout == .fixed else {
|
||||
return false
|
||||
@ -59,6 +78,7 @@ public final class RDEPUBPublication {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据偏好和视口生成固定版式的 spread 数组
|
||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
||||
EPUBFixedSpread.makeSpreads(
|
||||
spine: spine,
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
//
|
||||
// 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(
|
||||
@ -24,6 +43,8 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
/// 导航用的中间进度值,取 progression 和 lastProgression 的中点
|
||||
/// 用于在字号/横竖屏变化后估算最接近的页号
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
@ -32,17 +53,24 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
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(
|
||||
@ -60,10 +88,16 @@ public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口模型,描述当前屏幕可见区域的完整状态
|
||||
/// 可能跨越多个 spine 资源(如分栏布局时)
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
/// 当前视口覆盖的资源列表(可能有多个,如分栏或跨页场景)
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
/// 当前可见的页码
|
||||
public var visiblePageNumber: Int
|
||||
/// 当前章节索引
|
||||
public var chapterIndex: Int?
|
||||
/// 是否为固定版式内容
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
@ -79,10 +113,16 @@ public struct RDEPUBViewport: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读上下文,聚合了位置、视口和页码等完整阅读状态
|
||||
/// 由 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(
|
||||
@ -98,11 +138,17 @@ public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本选择模型,由 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(
|
||||
@ -119,28 +165,44 @@ public struct RDEPUBSelection: Codable, Equatable {
|
||||
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
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
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
|
||||
@ -148,16 +210,19 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
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),
|
||||
@ -170,28 +235,48 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本分页截断原因,描述为什么在当前位置分页
|
||||
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(
|
||||
@ -217,15 +302,26 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 高亮模型,表示用户在阅读中标记的文本片段
|
||||
/// 支持 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(
|
||||
@ -250,10 +346,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 编码键,用于 Codable 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
@ -266,6 +364,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case createdAt
|
||||
}
|
||||
|
||||
/// 自定义解码器,兼容旧版本数据格式(缺失字段使用默认值)
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
@ -284,6 +383,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
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)
|
||||
@ -298,12 +398,19 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
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(
|
||||
@ -322,14 +429,19 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// 是否包含用户备注
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节信息模型,描述单个章节的元数据
|
||||
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) {
|
||||
@ -339,12 +451,20 @@ public struct EPUBChapterInfo: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面模型,描述单页的完整信息
|
||||
/// 由 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(
|
||||
@ -364,10 +484,15 @@ public struct EPUBPage: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 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) {
|
||||
@ -378,23 +503,38 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式 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(
|
||||
@ -433,10 +573,13 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
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
|
||||
@ -453,7 +596,9 @@ public struct EPUBFixedSpread: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// String 扩展:空字符串转为 nil,用于 Codable 容错处理
|
||||
private extension String {
|
||||
/// 空白字符修剪后如果是空字符串则返回 nil
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
|
||||
@ -1,33 +1,56 @@
|
||||
// RDEPUBReadingSession.swift
|
||||
// EPUB 阅读会话(状态机)
|
||||
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
|
||||
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
|
||||
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
|
||||
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
|
||||
public final class RDEPUBReadingSession {
|
||||
/// 分页快照类型别名(页面数组 + 章节信息数组)
|
||||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
/// 关联的出版物
|
||||
public let publication: RDEPUBPublication
|
||||
|
||||
/// 当前导航状态
|
||||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||||
/// 当前生效的页面列表
|
||||
public private(set) var activePages: [EPUBPage] = []
|
||||
/// 当前生效的章节信息列表
|
||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||||
/// 暂存的待应用页面列表(分页计算完成后暂存,等状态允许时才生效)
|
||||
public private(set) var stagedPages: [EPUBPage]?
|
||||
/// 暂存的待应用章节信息列表
|
||||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||||
/// 暂存快照对应的恢复位置
|
||||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标位置(用于 fragment 精确定位)
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 当前视口信息
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||||
|
||||
public init(publication: RDEPUBPublication) {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 资源解析器快捷访问
|
||||
public var resourceResolver: RDEPUBResourceResolver {
|
||||
publication.resourceResolver
|
||||
}
|
||||
|
||||
/// 状态机转换
|
||||
public func transition(to state: RDEPUBNavigatorState) {
|
||||
navigatorState = state
|
||||
}
|
||||
|
||||
/// 重置所有运行时状态(回到 initializing)
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
@ -38,17 +61,20 @@ public final class RDEPUBReadingSession {
|
||||
currentReadingContext = nil
|
||||
}
|
||||
|
||||
/// 设置当前生效的分页快照
|
||||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
||||
activePages = snapshot.pages
|
||||
activeChapters = snapshot.chapters
|
||||
}
|
||||
|
||||
/// 暂存分页快照(待状态允许时才生效)
|
||||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||||
stagedPages = snapshot.pages
|
||||
stagedChapters = snapshot.chapters
|
||||
stagedRestoreLocation = restoreLocation
|
||||
}
|
||||
|
||||
/// 获取暂存的分页快照
|
||||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||||
guard let stagedPages, let stagedChapters else {
|
||||
return nil
|
||||
@ -56,6 +82,7 @@ public final class RDEPUBReadingSession {
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
@ -67,17 +94,20 @@ public final class RDEPUBReadingSession {
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
/// 清除暂存快照
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
/// 清除挂起的导航请求
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationLocation else {
|
||||
@ -93,6 +123,7 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||||
@ -100,6 +131,7 @@ public final class RDEPUBReadingSession {
|
||||
return page.spineIndex == spineIndex
|
||||
}
|
||||
|
||||
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
|
||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return RDEPUBLocation(
|
||||
@ -129,6 +161,7 @@ public final class RDEPUBReadingSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的位置
|
||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard currentPageNumber > 0,
|
||||
activePages.indices.contains(currentPageNumber - 1) else {
|
||||
@ -137,6 +170,7 @@ public final class RDEPUBReadingSession {
|
||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据位置获取初始 spine 索引
|
||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||||
guard let location,
|
||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||||
@ -146,6 +180,7 @@ public final class RDEPUBReadingSession {
|
||||
return spineIndex
|
||||
}
|
||||
|
||||
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
|
||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||||
return nil
|
||||
@ -186,6 +221,7 @@ public final class RDEPUBReadingSession {
|
||||
return candidates[localIndex].offset
|
||||
}
|
||||
|
||||
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@ -205,6 +241,7 @@ public final class RDEPUBReadingSession {
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
|
||||
public func updateReadingContext(
|
||||
pageNumber: Int,
|
||||
location: RDEPUBLocation,
|
||||
@ -253,10 +290,13 @@ public final class RDEPUBReadingSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
/// 根据分页计数和偏好设置生成分页快照
|
||||
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
|
||||
public func makePaginationSnapshot(
|
||||
pageCounts: [Int],
|
||||
preferences: RDEPUBPreferences,
|
||||
|
||||
@ -1,23 +1,49 @@
|
||||
//
|
||||
// RDEPUBRenderRequest.swift
|
||||
// RDReaderView
|
||||
//
|
||||
// EPUBCore 层渲染请求模型定义文件。
|
||||
// 包含渲染所需的展示样式(PresentationStyle)、可重排/固定版式渲染请求(RenderRequest)、
|
||||
// 固定版式适配模式(Fit)和 Spread 模式(SpreadMode)。
|
||||
// 由 RDEPUBPreferences 构建,传递给 RDEPUBWebView 执行渲染。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 固定版式页面适配模式
|
||||
/// - auto: 自动选择最佳适配策略
|
||||
/// - page: 适配整页(缩放至屏幕大小)
|
||||
/// - width: 适配宽度(按宽度等比缩放)
|
||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
||||
case auto
|
||||
case page
|
||||
case width
|
||||
}
|
||||
|
||||
/// 固定版式 Spread(双页)显示模式
|
||||
/// - automatic: 根据设备方向和屏幕尺寸自动判断
|
||||
/// - always: 始终启用双页显示
|
||||
/// - never: 始终单页显示
|
||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
||||
case automatic
|
||||
case always
|
||||
case never
|
||||
}
|
||||
|
||||
/// 渲染展示样式,WebView 和 Paginator 共用的视觉参数
|
||||
/// 由 RDEPUBPreferences 根据用户设置构建
|
||||
public struct RDEPUBPresentationStyle: Equatable {
|
||||
/// 视口尺寸(容器的可见区域大小)
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距(上下左右的安全区域偏移)
|
||||
public var contentInsets: UIEdgeInsets
|
||||
/// 字号(pt)
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数(1.0 为默认行距)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值,如 "#000000")
|
||||
public var themeTextColor: String?
|
||||
|
||||
public init(
|
||||
@ -37,14 +63,23 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可重排内容渲染请求,包含加载和渲染一个 reflowable spine 项所需的全部参数
|
||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
/// spine 索引
|
||||
public var spineIndex: Int
|
||||
/// 资源路径
|
||||
public var href: String
|
||||
/// 目标页在章节中的索引
|
||||
public var pageIndex: Int
|
||||
/// 该章节的总页数
|
||||
public var totalPagesInChapter: Int
|
||||
/// 展示样式
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 高亮列表
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@ -68,12 +103,19 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定版式渲染请求,包含加载和渲染一个 fixed spread 所需的全部参数
|
||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
/// 要渲染的 spread(包含 1-2 个资源)
|
||||
public var spread: EPUBFixedSpread
|
||||
/// 视口尺寸
|
||||
public var viewportSize: CGSize
|
||||
/// 内容内边距
|
||||
public var contentInset: UIEdgeInsets
|
||||
/// 背景色 CSS 值
|
||||
public var backgroundColorCSS: String?
|
||||
/// 页面适配模式(auto/page/width)
|
||||
public var fit: RDEPUBFixedLayoutFit
|
||||
/// 搜索结果展示信息
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
@ -93,10 +135,15 @@ public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染请求枚举,统一可重排和固定版式两种渲染模式
|
||||
/// RDEPUBWebView 根据此请求类型选择对应的加载和渲染路径
|
||||
public enum RDEPUBRenderRequest: Equatable {
|
||||
/// 可重排内容渲染请求
|
||||
case reflowable(RDEPUBReflowableRenderRequest)
|
||||
/// 固定版式渲染请求
|
||||
case fixed(RDEPUBFixedRenderRequest)
|
||||
|
||||
/// 是否为固定版式渲染
|
||||
public var isFixedLayout: Bool {
|
||||
if case .fixed = self {
|
||||
return true
|
||||
@ -104,6 +151,7 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 主资源的 spine 索引
|
||||
public var primarySpineIndex: Int {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
@ -113,6 +161,7 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主资源的路径
|
||||
public var primaryHref: String {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
|
||||
@ -1,28 +1,41 @@
|
||||
// RDEPUBResourceResolver.swift
|
||||
// EPUB 资源解析器
|
||||
// 封装 RDEPUBParser 的资源查询能力,提供 href 规范化、
|
||||
// spine 索引查找、位置规范化等高层接口。是 Services 层的核心组件,
|
||||
// 被 RDEPUBPublication 和 RDEPUBReadingSession 广泛使用。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EPUB 资源解析器,负责 URL 转换、href 规范化和位置解析
|
||||
public final class RDEPUBResourceResolver {
|
||||
/// 底层解析器引用
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
}
|
||||
|
||||
/// OPF 文件所在目录的 URL
|
||||
public var opfDirectoryURL: URL? {
|
||||
parser.opfDirectoryURL
|
||||
}
|
||||
|
||||
/// 将相对路径转换为本地文件 URL
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将相对路径转换为 ss-reader://book/ 协议 URL
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.resourceURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
/// 将 ss-reader://book/ 协议 URL 还原为本地文件 URL
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
/// 规范化 href:基于 OPF 目录和可选的 spine 索引,将相对路径解析为标准化的 OPF 内路径
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@ -57,6 +70,7 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 规范化 href:基于另一个 href 作为基准路径
|
||||
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
@ -79,6 +93,7 @@ public final class RDEPUBResourceResolver {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
/// 将相对于另一个 href 的引用转换为本地文件 URL
|
||||
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
|
||||
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
|
||||
return nil
|
||||
@ -86,6 +101,7 @@ public final class RDEPUBResourceResolver {
|
||||
return fileURL(forRelativePath: normalizedHref)
|
||||
}
|
||||
|
||||
/// 规范化位置对象:解析 href、保留 progression 和 fragment
|
||||
public func normalizedLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
@ -119,12 +135,14 @@ public final class RDEPUBResourceResolver {
|
||||
)
|
||||
}
|
||||
|
||||
/// 通过规范化 href 查找对应的 spine 索引
|
||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
||||
parser.spine.firstIndex { item in
|
||||
self.normalizedHref(item.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过位置查找对应的 spine 索引
|
||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let normalizedHref = normalizedHref(location.href) else {
|
||||
return nil
|
||||
@ -132,6 +150,7 @@ public final class RDEPUBResourceResolver {
|
||||
return spineIndex(forNormalizedHref: normalizedHref)
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取 href
|
||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@ -139,6 +158,7 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].href
|
||||
}
|
||||
|
||||
/// 通过 spine 索引获取标题
|
||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
@ -146,6 +166,7 @@ public final class RDEPUBResourceResolver {
|
||||
return parser.spine[spineIndex].title
|
||||
}
|
||||
|
||||
/// 通过 href 查询 manifest 项
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
||||
return parser.manifest.values.first {
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
// RDEPUBResourceURLSchemeHandler.swift
|
||||
// ss-reader:// 自定义 URL 协议处理器
|
||||
// 实现 WKURLSchemeHandler 协议,拦截 ss-reader://book/<path> 请求,
|
||||
// 将其映射到本地解压的 EPUB 资源文件。支持 MIME 类型推断和可选资源的空响应处理。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
/// 自定义协议名
|
||||
public static let scheme = "ss-reader"
|
||||
/// 自定义主机名
|
||||
public static let host = "book"
|
||||
|
||||
/// 底层解析器引用(弱引用,避免循环持有)
|
||||
private weak var parser: RDEPUBParser?
|
||||
/// 文件管理器实例
|
||||
private let fileManager = FileManager.default
|
||||
/// 串行队列,用于同步访问活跃任务字典
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
@ -15,6 +27,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
||||
syncQueue.sync {
|
||||
@ -72,23 +86,27 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
/// 取消 URL Scheme 任务
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
||||
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
||||
}
|
||||
|
||||
/// 从活跃任务集合中移除已完成的任务
|
||||
private func clearTask(_ id: ObjectIdentifier) {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查任务是否仍然活跃(防止取消后继续发送数据)
|
||||
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
||||
syncQueue.sync {
|
||||
activeTasks[id] == true
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm":
|
||||
@ -128,6 +146,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断文本编码(文本类文件返回 UTF-8)
|
||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
||||
@ -137,6 +156,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为可选资源(字体/图片/CSS/JS 等缺失时不报错,返回空响应)
|
||||
private static func isOptionalResource(_ url: URL) -> Bool {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
// RDEPUBSearchEngine.swift
|
||||
// EPUB 全文搜索引擎
|
||||
// 遍历 spine 中的线性 HTML/XHTML 资源,提取纯文本后执行大小写不敏感的关键词搜索,
|
||||
// 返回匹配项列表(含 href、progression、previewText 等)。
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 搜索引擎协议,定义全文搜索接口
|
||||
protocol RDEPUBSearchEngine {
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
/// HTML 全文搜索引擎实现
|
||||
/// 通过解析 HTML 为纯文本,逐章执行关键词匹配
|
||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
/// EPUB 解析器(用于读取 HTML 内容)
|
||||
private let parser: RDEPUBParser
|
||||
/// EPUB 出版物(用于遍历 spine 和规范化 href)
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
@ -14,6 +24,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 执行全文搜索:遍历所有线性 spine 项,提取纯文本后匹配关键词
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -35,6 +46,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return searchMatches
|
||||
}
|
||||
|
||||
/// 将 HTML 转换为纯文本(优先使用 NSAttributedString,失败时用正则兜底)
|
||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
@ -54,6 +66,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
/// 兜底方案:用正则去除 HTML 标签并解码 HTML 实体
|
||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
return stripped
|
||||
@ -65,6 +78,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
|
||||
/// 在纯文本中查找所有关键词匹配项(大小写不敏感)
|
||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let source = text as NSString
|
||||
let fullLength = source.length
|
||||
@ -106,6 +120,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 截取匹配位置前后的文本作为预览(前后各 12 个字符)
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
// RDEPUBSearchModels.swift
|
||||
// EPUB 搜索相关数据模型
|
||||
// 定义搜索匹配项(RDEPUBSearchMatch)、搜索结果(RDEPUBSearchResult)、
|
||||
// 搜索状态(RDEPUBSearchState)和搜索展示(RDEPUBSearchPresentation)等数据结构。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 搜索匹配项,包含匹配位置、进度、预览文本和范围信息
|
||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
/// 匹配所在资源的规范化 href
|
||||
public var href: String
|
||||
/// 匹配位置在文本中的进度比例(0.0 ~ 1.0)
|
||||
public var progression: Double
|
||||
/// 匹配位置前后的预览文本
|
||||
public var previewText: String
|
||||
/// 在当前资源内的本地匹配索引
|
||||
public var localMatchIndex: Int
|
||||
/// 匹配范围的起始位置(字符偏移量)
|
||||
public var rangeLocation: Int?
|
||||
/// 匹配范围的长度
|
||||
public var rangeLength: Int
|
||||
/// 文本范围锚点(用于精确定位)
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
@ -28,10 +41,15 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索结果摘要,包含关键词、总匹配数和当前匹配项
|
||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
public var keyword: String
|
||||
/// 总匹配数量
|
||||
public var totalMatchCount: Int
|
||||
/// 当前匹配项的索引(从 1 开始)
|
||||
public var currentMatchIndex: Int?
|
||||
/// 当前高亮的匹配项
|
||||
public var currentMatch: RDEPUBSearchMatch?
|
||||
|
||||
public init(
|
||||
@ -47,9 +65,13 @@ public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索状态,管理关键词、匹配列表和当前索引
|
||||
public struct RDEPUBSearchState: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
public var keyword: String
|
||||
/// 所有匹配项列表
|
||||
public var matches: [RDEPUBSearchMatch]
|
||||
/// 当前匹配项索引(从 0 开始)
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
||||
@ -58,6 +80,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
}
|
||||
|
||||
/// 获取当前匹配项
|
||||
public var currentMatch: RDEPUBSearchMatch? {
|
||||
guard let currentMatchIndex,
|
||||
matches.indices.contains(currentMatchIndex) else {
|
||||
@ -66,6 +89,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
return matches[currentMatchIndex]
|
||||
}
|
||||
|
||||
/// 转换为搜索结果摘要
|
||||
public var result: RDEPUBSearchResult {
|
||||
RDEPUBSearchResult(
|
||||
keyword: keyword,
|
||||
@ -76,9 +100,13 @@ public struct RDEPUBSearchState: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索展示的单个资源信息
|
||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
/// 资源的规范化 href
|
||||
public var href: String
|
||||
/// 该资源内的匹配数量
|
||||
public var matchCount: Int
|
||||
/// 当前活跃的本地匹配索引
|
||||
public var activeLocalMatchIndex: Int?
|
||||
|
||||
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
|
||||
@ -88,8 +116,11 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索展示信息,传递给 JS 桥接脚本用于渲染搜索高亮
|
||||
public struct RDEPUBSearchPresentation: Codable, Equatable {
|
||||
/// 搜索关键词
|
||||
public var keyword: String
|
||||
/// 各资源的搜索展示信息列表
|
||||
public var resources: [RDEPUBSearchPresentationResource]
|
||||
|
||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
// RDEPUBStyleSheetBuilder.swift
|
||||
// EPUB CSS 样式构建器
|
||||
// 负责生成重排模式下的分页 CSS(多列布局)和测量 CSS,
|
||||
// 以及将分页样式注入到 HTML 中。还提供测量脚本(JS)用于计算页数。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// CSS 样式构建工具集,生成分页、渲染和测量所需的 CSS/JS
|
||||
public enum RDEPUBStyleSheetBuilder {
|
||||
/// 将分页 CSS 注入到 HTML 字符串中(插入 </head> 或 <body 之前)
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
presentation: RDEPUBPresentationStyle
|
||||
@ -15,6 +22,8 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
return styleTag + html
|
||||
}
|
||||
|
||||
/// 生成重排模式的渲染 CSS(视口、body、#ss-reader-viewport、#ss-reader-content 等)
|
||||
/// 包含多列布局、字号、行高、主题色、高亮样式等
|
||||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
@ -95,6 +104,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成分页测量用的 CSS(与渲染 CSS 类似但不含 transform/will-change 等优化属性)
|
||||
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
@ -139,6 +149,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 生成分页测量脚本(JS):注入 CSS 后测量 scrollWidth,计算页数
|
||||
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let style = measurementCSS(for: presentation)
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
@ -170,6 +181,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
"""
|
||||
}
|
||||
|
||||
/// 从展示样式计算所有 CSS 变量值(视口尺寸、内边距、字号、行高等)
|
||||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
||||
viewportWidth: String,
|
||||
viewportHeight: String,
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
// RDEPUBTextAnchor.swift
|
||||
// EPUB 文本锚点和范围锚点
|
||||
// 定义精确的文本位置锚点(RDEPUBTextAnchor,含 fileIndex/row/column/chapterOffset/fragmentID),
|
||||
// 以及文本范围锚点(RDEPUBTextRangeAnchor,起止锚点对)。
|
||||
// 支持 Codable 序列化(兼容 spineIndex/fileIndex 两种 key)。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 文本锚点,精确定位到 EPUB 中的某个字符位置
|
||||
/// 包含文件索引、行号、列号、章节内偏移和可选的 fragment ID
|
||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
/// 文件索引(等同于 spineIndex)
|
||||
public let fileIndex: Int
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 列号(行内偏移)
|
||||
public let column: Int
|
||||
/// 章节内的绝对字符偏移量
|
||||
public let chapterOffset: Int
|
||||
/// 最近的 fragment ID(用于 URL 锚点定位)
|
||||
public let fragmentID: String?
|
||||
|
||||
/// spine 索引的别名(与 fileIndex 等价)
|
||||
public var spineIndex: Int { fileIndex }
|
||||
|
||||
public init(
|
||||
@ -53,8 +67,11 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本范围锚点,由起止锚点组成的区间
|
||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
/// 起始锚点
|
||||
public let start: RDEPUBTextAnchor
|
||||
/// 结束锚点
|
||||
public let end: RDEPUBTextAnchor
|
||||
|
||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
||||
@ -62,6 +79,7 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
self.end = end
|
||||
}
|
||||
|
||||
/// 转换为 NSRange(基于 chapterOffset)
|
||||
public var nsRange: NSRange {
|
||||
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
|
||||
}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
// RDEPUBWebView+Configuration.swift
|
||||
// RDEPUBWebView 的 WKWebView 配置扩展
|
||||
// 负责按需创建和配置 WKWebView 实例:注册 JS 桥接消息处理器、
|
||||
// 注入用户脚本、设置自定义 URL Scheme 处理器、配置滚动和外观属性。
|
||||
// 同时处理选择菜单(拷贝/高亮/批注)和 WebView 销毁清理。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 按需配置 WKWebView(仅在 Publication 变更或 WebView 不存在时重建)
|
||||
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
|
||||
let parser = publication.parser
|
||||
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
|
||||
@ -66,15 +73,18 @@ extension RDEPUBWebView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||
}
|
||||
|
||||
/// 处理选择菜单动作(拷贝/高亮/批注),通知委托并清除选择
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebView(self, didRequestSelectionAction: action)
|
||||
clearWebSelection()
|
||||
}
|
||||
|
||||
/// 清除 WebView 中的文本选择
|
||||
func clearWebSelection() {
|
||||
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
|
||||
}
|
||||
|
||||
/// 销毁 WebView,移除所有消息处理器和引用
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
if let webView {
|
||||
@ -96,6 +106,7 @@ extension RDEPUBWebView {
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS 十六进制颜色字符串(如 "#FF0000")
|
||||
var ss_hexString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
// RDEPUBWebView+FixedLayout.swift
|
||||
// RDEPUBWebView 的固定版式渲染扩展
|
||||
// 处理固定版式(Fixed Layout)的加载流程:生成 HTML 模板、
|
||||
// 计算加载签名(去重)、设置固定版式就绪回退定时器等。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载固定版式的 spread 页面
|
||||
public func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
@ -22,6 +28,7 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理固定版式加载:设置状态、生成 HTML、加载到 WebView
|
||||
func handleFixedLayoutLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBFixedRenderRequest,
|
||||
@ -52,6 +59,7 @@ extension RDEPUBWebView {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成固定版式加载签名(用于去重相同请求)
|
||||
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
||||
[
|
||||
publicationKey,
|
||||
@ -68,6 +76,7 @@ extension RDEPUBWebView {
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 调度固定版式就绪回退定时器(1 秒后如果未收到 ready 消息则手动触发渲染完成)
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
||||
@ -79,6 +88,7 @@ extension RDEPUBWebView {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
||||
}
|
||||
|
||||
/// 取消固定版式就绪回退定时器
|
||||
func cancelFixedLayoutReadyFallback() {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
// RDEPUBWebView+JavaScriptBridge.swift
|
||||
// RDEPUBWebView 的 JS 桥接和导航委托扩展
|
||||
// 实现 WKNavigationDelegate 处理页面加载生命周期,
|
||||
// 实现 WKScriptMessageHandler 接收 JS 消息(进度/选择/链接/错误/固定版式就绪),
|
||||
// 并将内部链接和外部链接路由到委托方法。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 从 URL 解析内部位置(ss-reader:// 协议的链接)
|
||||
func internalLocation(from url: URL) -> RDEPUBLocation? {
|
||||
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
let fragment = url.fragment
|
||||
@ -9,6 +16,7 @@ extension RDEPUBWebView {
|
||||
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
|
||||
}
|
||||
|
||||
/// 从 JS 消息体中构建当前位置对象
|
||||
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
||||
@ -43,6 +51,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
}
|
||||
|
||||
/// 导航策略决策:区分内部链接(ss-reader://)、外部链接(http/https/mailto/tel)和其他导航
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
|
||||
guard navigationAction.navigationType == .linkActivated,
|
||||
@ -69,6 +78,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
}
|
||||
|
||||
extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
/// 接收 JS 桥接消息,根据消息类型路由到对应的委托方法
|
||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
|
||||
switch message.name {
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
// RDEPUBWebView+Reflowable.swift
|
||||
// RDEPUBWebView 的重排模式渲染扩展
|
||||
// 处理重排(Reflowable)模式的加载流程:构建渲染请求、
|
||||
// 生成加载签名(去重)、通过 JS 桥接应用分页样式和高亮、
|
||||
// 处理重复请求跳过和已有文档复用等优化逻辑。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 便捷方法:加载重排模式的指定页面
|
||||
public func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
@ -39,6 +46,7 @@ extension RDEPUBWebView {
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
/// 处理重排模式加载:设置状态、去重检查、加载资源或直接应用样式
|
||||
func handleReflowableLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBReflowableRenderRequest,
|
||||
@ -83,6 +91,7 @@ extension RDEPUBWebView {
|
||||
webView.load(URLRequest(url: requestURL))
|
||||
}
|
||||
|
||||
/// 生成重排模式加载签名(包含所有渲染参数的哈希,用于去重)
|
||||
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let targetSignature = [
|
||||
request.targetLocation?.href ?? "",
|
||||
@ -116,6 +125,7 @@ extension RDEPUBWebView {
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
/// 应用分页展示样式:通过 JS 桥接注入 CSS、设置分页参数和高亮,然后触发渲染完成
|
||||
func applyPresentation() {
|
||||
guard let webView, let currentRenderRequest else { return }
|
||||
guard case .reflowable(let request) = currentRenderRequest else {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
// RDEPUBWebView+Search.swift
|
||||
// RDEPUBWebView 的搜索高亮扩展
|
||||
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
||||
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
completion?()
|
||||
|
||||
@ -1,21 +1,39 @@
|
||||
// RDEPUBWebView.swift
|
||||
// EPUB WebView 主类
|
||||
// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。
|
||||
// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、
|
||||
// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。
|
||||
// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
|
||||
public protocol RDEPUBWebViewDelegate: AnyObject {
|
||||
/// 阅读位置更新事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// 文本选择变化事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 选择菜单动作请求事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 内部链接点击事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 外部链接点击事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
|
||||
/// JavaScript 错误事件
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||
/// 页面渲染完成事件
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
||||
}
|
||||
|
||||
/// 委托协议的默认实现(选择菜单动作为可选)
|
||||
public extension RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
|
||||
}
|
||||
|
||||
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
|
||||
final class RDEPUBAnnotationWebView: WKWebView {
|
||||
/// 选择菜单动作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
@ -46,33 +64,61 @@ final class RDEPUBAnnotationWebView: WKWebView {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
|
||||
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
|
||||
public final class RDEPUBWebView: UIView {
|
||||
/// 委托对象,接收渲染和交互事件
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
/// 渲染完成回调(与 delegate 并行使用)
|
||||
public var onRendered: (() -> Void)?
|
||||
|
||||
/// 当前关联的出版物
|
||||
var publication: RDEPUBPublication?
|
||||
/// 当前渲染请求
|
||||
var currentRenderRequest: RDEPUBRenderRequest?
|
||||
/// 内部 WKWebView 实例
|
||||
var webView: WKWebView?
|
||||
/// URL Scheme 处理器
|
||||
var schemeHandler: RDEPUBResourceURLSchemeHandler?
|
||||
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
|
||||
var configuredPublicationKey: String?
|
||||
/// 当前加载签名(用于去重相同请求)
|
||||
var currentLoadSignature: String?
|
||||
/// 当前 spine 索引
|
||||
var currentSpineIndex = 0
|
||||
/// 当前资源 href
|
||||
var currentHref = ""
|
||||
/// 当前页码(从 0 开始)
|
||||
var currentPageIndex = 0
|
||||
/// 当前章节总页数
|
||||
var currentTotalPagesInChapter = 1
|
||||
/// 当前视口尺寸
|
||||
var viewportSize: CGSize = .zero
|
||||
/// 当前内容内边距
|
||||
var currentPadding: UIEdgeInsets = .zero
|
||||
/// 当前字号
|
||||
var currentFontSize: CGFloat = 16
|
||||
/// 当前行高倍数
|
||||
var currentLineHeightMultiple: CGFloat = 1.5
|
||||
/// 当前主题背景色
|
||||
var currentThemeBackgroundColor: String?
|
||||
/// 当前主题文字色
|
||||
var currentThemeTextColor: String?
|
||||
/// 目标跳转位置
|
||||
var targetLocation: RDEPUBLocation?
|
||||
/// 待应用的高亮列表
|
||||
var pendingHighlights: [RDEPUBHighlight] = []
|
||||
/// 当前固定版式 spread(固定版式时非空)
|
||||
var fixedSpread: EPUBFixedSpread?
|
||||
/// 是否为固定版式模式
|
||||
var isFixedLayout = false
|
||||
/// 是否有待处理的进度汇报请求
|
||||
var pendingProgressionRequest = false
|
||||
/// 固定版式就绪回退定时器
|
||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
/// 当前请求是否已完成渲染
|
||||
var didRenderCurrentRequest = false
|
||||
/// 调试日志作用域
|
||||
let debugScope = "ReaderWebView"
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@ -93,6 +139,7 @@ public final class RDEPUBWebView: UIView {
|
||||
webView?.frame = bounds
|
||||
}
|
||||
|
||||
/// 重置所有状态并销毁 WebView
|
||||
public func reset() {
|
||||
delegate = nil
|
||||
onRendered = nil
|
||||
@ -117,6 +164,7 @@ public final class RDEPUBWebView: UIView {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
|
||||
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
configureWebViewIfNeeded(publication: publication)
|
||||
guard let webView else { return }
|
||||
@ -143,6 +191,7 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成页面加载签名
|
||||
func pageLoadSignature(
|
||||
publicationKey: String,
|
||||
request: RDEPUBRenderRequest
|
||||
@ -155,6 +204,7 @@ public final class RDEPUBWebView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
|
||||
func rendered() {
|
||||
guard !didRenderCurrentRequest else { return }
|
||||
didRenderCurrentRequest = true
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
// RDEPUBWebViewDebug.swift
|
||||
// WebView 调试日志工具
|
||||
// 提供统一的调试日志输出接口,支持导航事件、JS 执行、
|
||||
// 消息接收、URL Scheme 任务等场景的日志记录。
|
||||
// DEBUG 模式默认开启,Release 模式默认关闭,可通过 UserDefaults 覆盖。
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// WebView 调试日志工具集
|
||||
enum RDEPUBWebViewDebug {
|
||||
/// 调试日志是否启用(DEBUG 默认开启,可通过 UserDefaults "RDEPUBWebViewDebugEnabled" 覆盖)
|
||||
static var isEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
|
||||
return configured
|
||||
@ -13,16 +21,19 @@ enum RDEPUBWebViewDebug {
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
return String(ObjectIdentifier(webView).hashValue, radix: 16)
|
||||
}
|
||||
|
||||
/// 输出通用日志
|
||||
static func log(_ scope: String, message: String) {
|
||||
guard isEnabled else { return }
|
||||
print("[RDReaderWK][\(scope)] \(message)")
|
||||
}
|
||||
|
||||
/// 输出导航事件日志(含 WebView ID、事件类型、URL、错误信息)
|
||||
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let webViewToken = webViewID(webView)
|
||||
@ -34,16 +45,19 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 JavaScript 执行日志
|
||||
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
}
|
||||
|
||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let requestText = summarizedURL(requestURL)
|
||||
@ -55,6 +69,7 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取 URL 的摘要信息(ss-reader:// 协议显示完整 URL,其他显示最后路径组件)
|
||||
static func summarizedURL(_ url: URL?) -> String {
|
||||
guard let url else { return "nil" }
|
||||
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// 章节数据访问层:为已分页章节提供便捷的查询接口。
|
||||
///
|
||||
/// 本类是 `RDEPUBTextChapter` 的轻量级封装,持有 `indexTable`(全局索引表),
|
||||
/// 将分页数据、fragment 锚点、高亮、搜索结果等统一到同一套查询 API 中。
|
||||
/// 由 `RDEPUBTextBook.chapterData(for:)` 或 `chapterData(atChapterIndex:)` 创建。
|
||||
public final class RDEPUBChapterData {
|
||||
/// 底层章节模型(只读)
|
||||
public let chapter: RDEPUBTextChapter
|
||||
/// 全局索引表,用于 fileIndex/row/column 到绝对偏移量的映射
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
// MARK: - 便捷属性(转发自 chapter)
|
||||
|
||||
/// 章节在全书中的序号
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
/// spine 顺序索引
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
/// 章节文件相对路径(如 "OEBPS/chapter1.xhtml")
|
||||
public var href: String { chapter.href }
|
||||
/// 章节标题
|
||||
public var title: String { chapter.title }
|
||||
/// 章节完整的富文本内容
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
/// 分页后的页面列表
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
/// fragment ID → 字符偏移量的映射表(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
|
||||
// MARK: - 页面查询
|
||||
|
||||
/// 查找包含指定绝对字符偏移量的页面
|
||||
/// - Parameter absoluteOffset: 全书绝对字符偏移量
|
||||
/// - Returns: 包含该偏移量的页面,未找到返回 nil
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
/// 获取指定绝对偏移量所在页的绝对页码(从 0 开始)
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
/// 按绝对页码查找页面
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
// MARK: - 锚点与位置映射
|
||||
|
||||
/// 将绝对字符索引转换为语义锚点(fileIndex/row/column 三元组)
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为起止锚点对
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
/// 从绝对字符范围构建选区对象(用于复制/高亮分享)
|
||||
/// - Parameters:
|
||||
/// - absoluteRange: 选区在全书中的字符范围
|
||||
/// - bookIdentifier: 书籍标识符
|
||||
/// - Returns: 选区对象,若起始偏移量不在任何页面内则返回 nil
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
|
||||
@ -56,6 +88,7 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将绝对字符范围转换为持久化位置对象(RDEPUBLocation)
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将页面转换为对应的 RDEPUBLocation
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
// MARK: - 位置反向解析(Location → 绝对偏移量)
|
||||
|
||||
/// 将持久化位置还原为章节内的绝对字符范围。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor(锚点定位,最精确)
|
||||
/// 2. fragment(片段 ID 定位)
|
||||
/// 3. navigationProgression(进度百分比回退)
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
/// 从高亮的持久化位置还原绝对字符范围
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 高亮/搜索结果与页面的交叉查询
|
||||
|
||||
/// 获取指定页面上出现的所有高亮
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定页面上出现的所有搜索匹配结果
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据持久化位置获取对应页码(从 1 开始),用于阅读进度跳转
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 私有工具方法
|
||||
|
||||
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
/// 将语义锚点转换为绝对字符偏移量,若索引表无法解析则回退到 chapterOffset
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
|
||||
@ -4,9 +4,18 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// DTCoreText 渲染器实现:将 EPUB 章节 HTML 转换为富文本(NSAttributedString)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextRenderer` 协议的默认实现,位于渲染链路的第二步:
|
||||
/// 章节 HTML → DTCoreText 渲染 → NSAttributedString → 提取 fragment 偏移量 → 统一字体/行距
|
||||
///
|
||||
/// DTCoreText 库负责将 HTML 解析为带有排版属性的富文本,
|
||||
/// 渲染过程中会通过 `willFlushCallback` 回调对每个 DOM 元素做布局规范化(图片尺寸等)。
|
||||
/// 当 DTCoreText 不可用时(条件编译失败),回退到纯文本渲染。
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
/// DTCoreText 库是否在当前编译环境中可用
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 渲染单个章节:HTML → NSAttributedString,同时提取 fragment 和语义标记。
|
||||
///
|
||||
/// 渲染流程:
|
||||
/// 1. 将 HTML 字符串编码为 Data
|
||||
/// 2. 通过 DTCoreText 解析为富文本(失败则回退到纯文本)
|
||||
/// 3. 注入分页语义标记(${rd-sem-start/end} → 属性字典)
|
||||
/// 4. 提取 fragment 偏移量映射表
|
||||
/// 5. 规范化阅读属性(字体、行距、颜色统一)
|
||||
public func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 便捷方法:直接传入 HTML 字符串进行渲染(不含上下文信息)
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return try renderChapter(request: request)
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理
|
||||
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
|
||||
@ -71,6 +90,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreText 将 HTML Data 解析为富文本。
|
||||
///
|
||||
/// 通过 `willFlushCallback` 回调,在每个 DOM 元素最终写入富文本前,
|
||||
/// 对图片附件做尺寸规范化、判断是否为脚注/封面等特殊元素。
|
||||
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
/// 构建 DTCoreText 的解析选项字典,包括字体、行高、图片尺寸限制等。
|
||||
///
|
||||
/// - 注意:行高倍率计算公式为 `(字体行高 + 行间距) / 字体行高`,
|
||||
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||
let style = request.style
|
||||
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||
|
||||
@ -1,48 +1,80 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 章节分页诊断数据
|
||||
|
||||
/// 单个章节的分页诊断信息,用于调试和质量检测。
|
||||
/// 记录页数、分页原因、附件/块级元素统计等。
|
||||
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var pageCount: Int
|
||||
/// 每页的分页原因列表
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
/// 包含附件的页数
|
||||
public var attachmentPageCount: Int
|
||||
/// 因块级/附件边界调整而分页的页数
|
||||
public var blockAdjustedPageCount: Int
|
||||
public var blockKinds: [RDEPUBTextBlockKind]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 采样诊断日志(最多 4 条)
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
///
|
||||
/// 每个 `RDEPUBTextPage` 由 `RDEPUBTextLayoutFrame` 生成,
|
||||
/// `contentRange` 标记了该页在章节富文本中的字符范围。
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
/// 全书绝对页码(从 0 开始)
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
/// 该页在所属章节中的相对页码(从 0 开始)
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
/// 所属章节的完整富文本内容(用于跨页查询)
|
||||
public var chapterContent: NSAttributedString
|
||||
/// 该页的富文本片段
|
||||
public var content: NSAttributedString
|
||||
/// 该页在章节富文本中的范围
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
/// 页的元数据(分页原因、附件、语义标记等)
|
||||
public var metadata: RDEPUBTextPageMetadata
|
||||
}
|
||||
|
||||
// MARK: - 章节数据模型
|
||||
|
||||
/// 渲染并分页后的单个章节,包含完整富文本和分页结果。
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
/// 章节完整富文本内容
|
||||
public var attributedContent: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射(用于锚点定位)
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍模型
|
||||
|
||||
/// 整本 EPUB 的分页书籍模型,包含所有章节、页面和全局索引表。
|
||||
///
|
||||
/// 这是 EPUBTextRendering 层的最终产物,由 `RDEPUBTextBookBuilder.build()` 生成。
|
||||
/// 通过 `chapterData(for:)` 或 `chapterData(atChapterIndex:)` 获取 `RDEPUBChapterData` 进行查询。
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
/// 全局索引表:fileIndex/row/column → 绝对字符偏移量
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
/// 按 href 获取章节的数据访问层(包含索引表)
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按章节序号获取章节的数据访问层
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
/// 按页码(从 1 开始)获取对应页面
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
/// 根据持久化位置计算对应页码(从 1 开始)。
|
||||
///
|
||||
/// 解析优先级:
|
||||
/// 1. rangeAnchor 锚点定位
|
||||
/// 2. fragment 片段 ID
|
||||
/// 3. navigationProgression 进度百分比回退
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
/// 根据页码生成持久化位置(RDEPUBLocation),包含起止锚点
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
@ -121,14 +163,29 @@ public struct RDEPUBTextBook {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分页书籍构建器
|
||||
|
||||
/// 从 EPUB publication 构建分页书籍模型的核心构建器。
|
||||
///
|
||||
/// 渲染链路:遍历 spine → 渲染每章 HTML → 分页 → 合并/规范化尾页 → 构建 RDEPUBTextBook
|
||||
///
|
||||
/// 特性:
|
||||
/// - 支持分页缓存(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
/// - 性能采样(记录每章渲染/分页耗时)
|
||||
/// - 尾页规范化(丢弃纯空白尾页、合并过短尾页)
|
||||
/// - 封面章节特殊处理
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
private let cache: RDEPUBTextBookCache?
|
||||
private let sampler: RDEPUBTextPerformanceSampler
|
||||
|
||||
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
||||
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||||
/// 最后一次构建的分页诊断(每章一页的分页原因、附件统计等)
|
||||
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
|
||||
/// 最后一次构建的性能采样数据
|
||||
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 最后一次构建的缓存命中/未命中统计
|
||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
|
||||
@ -137,10 +194,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
self.sampler = RDEPUBTextPerformanceSampler()
|
||||
}
|
||||
|
||||
/// 默认构造器,使用 DTCoreText 渲染器
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
||||
|
||||
@ -164,6 +223,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 核心构建方法:从 EPUB publication 构建分页书籍。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 生成缓存键,尝试加载分页缓存
|
||||
/// 2. 遍历 spine 中的线性 HTML 章节
|
||||
/// 3. 渲染每章 HTML → NSAttributedString
|
||||
/// 4. 跳过空白的封面/扉页章节
|
||||
/// 5. 分页:缓存命中则使用缓存的页范围,否则调用 CoreText 分页引擎
|
||||
/// 6. 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
/// 7. 构建 RDEPUBTextBook 并保存分页缓存
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@ -180,7 +249,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// Cache lookup — WXRead style: load per-chapter page ranges
|
||||
// 缓存查询 — WXRead 模式:只加载每章的页范围,不缓存富文本
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
|
||||
@ -191,6 +260,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: item.href,
|
||||
@ -201,6 +271,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
resourceResolver: publication.resourceResolver
|
||||
)
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
@ -211,6 +282,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
@ -221,10 +293,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
@ -245,7 +319,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// Cache hit: use cached page ranges, skip rd_paginatedFrames
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
@ -263,6 +337,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
||||
: []
|
||||
@ -270,6 +345,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = normalizeTrailingFrames(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
@ -298,6 +374,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
@ -312,6 +389,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
@ -369,6 +447,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
|
||||
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
if let cacheKey {
|
||||
let paginationCache = chapters.map { chapter in
|
||||
let pageRanges = chapter.pages.map(\.contentRange)
|
||||
@ -390,6 +469,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
@ -400,12 +482,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
/// 递归展开嵌套目录为扁平列表
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节过滤
|
||||
|
||||
/// 判断是否应跳过该章节(空白的封面/扉页,无文本且无附件)
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
var hasAttachment = false
|
||||
@ -422,6 +508,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - 附件统计
|
||||
|
||||
/// 统计富文本中的附件数量
|
||||
private func attachmentCount(in content: NSAttributedString) -> Int {
|
||||
guard content.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@ -433,6 +522,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
/// 获取富文本中所有附件的 NSRange 列表
|
||||
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
|
||||
guard content.length > 0 else { return [] }
|
||||
var ranges: [NSRange] = []
|
||||
@ -444,6 +534,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - 封面章节检测
|
||||
|
||||
/// 判断是否为纯图片封面章节(href 包含 cover 且有附件但几乎无文本)
|
||||
private func isAttachmentOnlyCoverChapter(
|
||||
item: RDEPUBSpineItem,
|
||||
content: NSAttributedString,
|
||||
@ -455,6 +548,11 @@ public final class RDEPUBTextBookBuilder {
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
// MARK: - 尾页规范化
|
||||
|
||||
/// 规范化分页结果的尾部页面:
|
||||
/// 1. 丢弃纯空白的尾页
|
||||
/// 2. 将过短的尾页(≤2 字符)合并到前一页
|
||||
private func normalizeTrailingFrames(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
@ -464,6 +562,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
var normalized = frames
|
||||
|
||||
// 丢弃纯空白的尾页
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
@ -476,6 +575,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// 将过短的尾页合并到前一页
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
@ -489,6 +589,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return normalized
|
||||
}
|
||||
|
||||
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
||||
private func shouldDropWhitespaceOnlyTrailingFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
@ -500,6 +601,8 @@ public final class RDEPUBTextBookBuilder {
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
/// 判断尾页是否过短需要合并到前一页。
|
||||
/// 条件:尾页 ≤ 2 个可见字符,且前一页的字符数是尾页的 8 倍以上(至少 12 个字符)
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
@ -522,6 +625,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
/// 将尾页合并到前一页,保留尾页的分页原因
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
@ -547,6 +651,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
)
|
||||
}
|
||||
|
||||
/// 统计指定范围内可见字符数(排除空白和控制字符)
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
@ -560,6 +665,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
// MARK: - 集合工具方法
|
||||
|
||||
/// 数组去重(保持顺序)
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@ -568,6 +676,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// NSRange 数组去重(保持顺序)
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@ -576,6 +685,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计指定范围内附件数量
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
@ -587,6 +697,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
return count
|
||||
}
|
||||
|
||||
// MARK: - 缓存键生成
|
||||
|
||||
/// 生成缓存键:基于书籍 ID、字号、行距、页面尺寸等参数的 SHA256 哈希
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
|
||||
// MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本)
|
||||
|
||||
/// Per-chapter pagination metadata cached to disk.
|
||||
/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons,
|
||||
/// NOT the full attributed string. Builder re-renders HTML on cache hit but
|
||||
/// skips the rd_paginatedFrames pagination step.
|
||||
/// 单个章节的分页元数据缓存,用于磁盘持久化。
|
||||
///
|
||||
/// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因,
|
||||
/// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。
|
||||
public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
/// 章节文件相对路径
|
||||
public var href: String
|
||||
/// 每页在章节富文本中的字符范围
|
||||
public var pageRanges: [NSRange]
|
||||
/// 每页的分页原因(语义边界、帧限制等)
|
||||
public var breakReasons: [RDEPUBTextPageBreakReason]
|
||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
||||
|
||||
@ -26,8 +29,10 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSCoding Archive (simple: strings + ints only)
|
||||
// MARK: - NSCoding 归档层(只使用字符串和整数类型)
|
||||
|
||||
/// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。
|
||||
/// 包含所有章节的分页归档数据。
|
||||
final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
|
||||
@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。
|
||||
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
static var supportsSecureCoding: Bool { true }
|
||||
let href: String
|
||||
/// 页范围的起始位置数组(与 rangeLengths 一一对应)
|
||||
let rangeLocations: [NSNumber]
|
||||
/// 页范围的长度数组
|
||||
let rangeLengths: [NSNumber]
|
||||
/// 分页原因的原始字符串数组
|
||||
let breakReasons: [String]
|
||||
/// 语义提示的原始字符串数组
|
||||
let semanticHints: [String]
|
||||
|
||||
/// 从缓存模型构建归档对象
|
||||
init(from cache: RDEPUBTextChapterPaginationCache) {
|
||||
self.href = cache.href
|
||||
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
|
||||
@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
self.semanticHints = semanticHints
|
||||
}
|
||||
|
||||
/// 将归档数据转换回缓存模型
|
||||
func toCache() -> RDEPUBTextChapterPaginationCache {
|
||||
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
|
||||
NSRange(location: loc.intValue, length: len.intValue)
|
||||
@ -99,19 +111,27 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RDEPUBTextBookCache
|
||||
// MARK: - 分页缓存管理器
|
||||
|
||||
/// Disk-persistent cache for per-chapter pagination metadata.
|
||||
/// Follows WXRead's WRChapterPageCount caching pattern:
|
||||
/// cache key = bookID + layout settings, cache value = page NSRange per chapter.
|
||||
/// Attributed strings are NOT cached — builder re-renders HTML on cache hit.
|
||||
/// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。
|
||||
///
|
||||
/// 缓存策略:
|
||||
/// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本)
|
||||
/// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化)
|
||||
/// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤
|
||||
/// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行
|
||||
public final class RDEPUBTextBookCache {
|
||||
|
||||
/// 缓存模式版本号,变更时旧缓存自动失效
|
||||
public var schemaVersion: Int = 1
|
||||
|
||||
/// 串行队列,保证缓存读写的线程安全
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||
/// 缓存文件目录
|
||||
private let cacheDirectory: URL
|
||||
|
||||
/// 初始化缓存管理器,自动创建缓存目录
|
||||
/// - Parameter subdirectory: Caches 目录下的子目录名
|
||||
public init(subdirectory: String = "RDEPUBTextBookCache") {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
@ -120,10 +140,13 @@ public final class RDEPUBTextBookCache {
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Cache Key
|
||||
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
// MARK: - 缓存键生成
|
||||
// 对标 WRChapterPageCount.currentCacheKeyWithBookId:
|
||||
// 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
|
||||
|
||||
/// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。
|
||||
///
|
||||
/// 任意布局参数变更都会导致缓存键不同,从而自动失效。
|
||||
public func cacheKey(
|
||||
bookID: String,
|
||||
fontSize: CGFloat,
|
||||
@ -137,10 +160,10 @@ public final class RDEPUBTextBookCache {
|
||||
return hex + ".cache"
|
||||
}
|
||||
|
||||
// MARK: - Load / Save (pagination metadata only)
|
||||
// MARK: - 加载/保存(只缓存分页元数据)
|
||||
|
||||
/// Load cached pagination metadata for all chapters.
|
||||
/// Returns dictionary keyed by chapter href.
|
||||
/// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。
|
||||
/// 缓存未命中或反序列化失败时返回 nil。
|
||||
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@ -170,7 +193,7 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save pagination metadata for all chapters.
|
||||
/// 将分页元数据保存到磁盘(原子写入,防止损坏)
|
||||
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
@ -186,8 +209,9 @@ public final class RDEPUBTextBookCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invalidate
|
||||
// MARK: - 缓存失效
|
||||
|
||||
/// 清除所有缓存文件
|
||||
public func invalidateAll() {
|
||||
queue.sync {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
// RDEPUBTextIndexTable.swift
|
||||
// EPUB 文本索引表
|
||||
// 构建全书的文本索引映射:章节起始偏移、href 与章节/spine 索引的对应关系、
|
||||
// fragment 偏移映射、以及行列索引映射。支持锚点与位置之间的双向转换,
|
||||
// 用于精确定位文本选择和搜索匹配。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 行-列索引条目,表示文本中某一行的字符范围
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
/// 行号
|
||||
public let row: Int
|
||||
/// 该行的起始字符偏移
|
||||
public let startOffset: Int
|
||||
/// 该行的结束字符偏移
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
@ -11,20 +21,30 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
/// 判断给定偏移量是否在该行范围内
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本索引表,存储全书的文本结构映射
|
||||
public struct RDEPUBTextIndexTable {
|
||||
/// 各章节的起始绝对偏移量(累加长度)
|
||||
public let chapterStartOffsets: [Int]
|
||||
/// 各章节的文本长度
|
||||
public let chapterLengths: [Int]
|
||||
/// href → 章节索引的映射
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
/// href → spine 文件索引的映射
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
/// href → (fragment ID → 章节内偏移) 的映射
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
/// spine 文件索引 → href 的反向映射
|
||||
public let fileIndexToHref: [Int: String]
|
||||
/// spine 文件索引 → 行列索引数组的映射
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
/// 从文本章节列表构建索引表
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
@ -55,6 +75,7 @@ public struct RDEPUBTextIndexTable {
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
}
|
||||
|
||||
/// 根据绝对索引和章节构建文本锚点
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, in: chapter)
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
||||
@ -69,6 +90,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据阅读位置构建文本锚点(优先使用 rangeAnchor,其次根据 fragment 或 progression 计算)
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
@ -96,6 +118,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据锚点查找对应的绝对页码
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
@ -105,14 +128,17 @@ public struct RDEPUBTextIndexTable {
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
/// 通过文件索引获取 href
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
/// 通过 href 获取章节索引
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
@ -121,12 +147,14 @@ public struct RDEPUBTextIndexTable {
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为全书绝对 NSRange
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
/// 将单个锚点转换为阅读位置(计算 progression)
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
@ -145,6 +173,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将范围锚点转换为阅读位置(起止 progression)
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
@ -165,6 +194,7 @@ public struct RDEPUBTextIndexTable {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在行号
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
@ -173,6 +203,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
/// 根据绝对索引和文件索引查找所在列号(行内偏移)
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
@ -182,6 +213,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
/// 根据文件索引、行号和列号计算全书绝对索引
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
@ -190,11 +222,13 @@ public struct RDEPUBTextIndexTable {
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
/// 将偏移量限制在章节范围内
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
/// 查找距离给定偏移量最近(且在其之前)的 fragment ID
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
@ -209,6 +243,7 @@ public struct RDEPUBTextIndexTable {
|
||||
return bestID
|
||||
}
|
||||
|
||||
/// 从文本字符串构建行列索引数组(按行分割,记录每行的起止偏移)
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
@ -1,17 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// CoreText 分页引擎产生的单帧(单页)布局数据。
|
||||
///
|
||||
/// 由 `RDEPUBTextLayouter` 在分页过程中生成,记录了该页的内容范围、分页原因、
|
||||
/// 附件信息和语义标记。`RDEPUBTextBookBuilder` 会将 `RDEPUBTextLayoutFrame` 转换为
|
||||
/// `RDEPUBTextPage`,并纳入最终的 `RDEPUBTextBook` 模型。
|
||||
struct RDEPUBTextLayoutFrame: Equatable {
|
||||
/// 该帧在章节富文本中的字符范围
|
||||
var contentRange: NSRange
|
||||
/// 分页原因(语义边界、附件边界、帧限制等)
|
||||
var breakReason: RDEPUBTextPageBreakReason
|
||||
/// 所属 HTML 块级元素的范围(用于调试)
|
||||
var blockRange: NSRange?
|
||||
/// 帧内附件的字符范围列表
|
||||
var attachmentRanges: [NSRange]
|
||||
/// 帧内附件类型列表(图片、通用附件等)
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind]
|
||||
/// 帧内包含的 HTML 块级元素类型列表(段落、列表、表格等)
|
||||
var blockKinds: [RDEPUBTextBlockKind]
|
||||
/// 帧内语义提示列表(避免分页、保持与下一段同页等)
|
||||
var semanticHints: [RDEPUBTextSemanticHint]
|
||||
/// 帧内附件的布局方式(行内、基线、居中)
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
||||
/// 帧尾部最近的 fragment ID(用于恢复阅读位置)
|
||||
var trailingFragmentID: String?
|
||||
/// 诊断日志列表(分页原因详情、范围信息等)
|
||||
var diagnostics: [String]
|
||||
|
||||
/// 将布局帧数据转换为页元数据模型
|
||||
var metadata: RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: breakReason,
|
||||
|
||||
@ -5,11 +5,29 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// CoreText 分页引擎:将富文本按页面尺寸拆分为多帧(每帧对应一页)。
|
||||
///
|
||||
/// 分页策略优先级(从高到低):
|
||||
/// 1. avoidPageBreakInside — 不在保护块内分页(对标 WXRead 的行级回退扫描)
|
||||
/// 2. keepWithNext — 标题等元素需与下一段同页
|
||||
/// 3. 语义边界 — pageBreakBefore/After 等显式分页标记
|
||||
/// 4. pageRelate — 微信读书式的跨页关联元素
|
||||
/// 5. 附件边界 — 块级附件应整体移到下一页
|
||||
/// 6. 帧限制 — 默认按 CoreText 可视范围分页
|
||||
///
|
||||
/// 支持两条渲染路径:
|
||||
/// - DTCoreText 路径(首选):DTCoreTextLayouter → DTCoreTextLayoutFrame
|
||||
/// - CoreText 回退路径:CTFramesetterCreateFrame
|
||||
struct RDEPUBTextLayouter {
|
||||
/// 待分页的富文本
|
||||
private let attributedString: NSAttributedString
|
||||
/// 页面尺寸(决定每帧能容纳多少内容)
|
||||
private let pageSize: CGSize
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
/// 页面矩形路径(用于 CTFrame 排版)
|
||||
private let path: CGPath
|
||||
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
|
||||
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
|
||||
@ -20,6 +38,8 @@ struct RDEPUBTextLayouter {
|
||||
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||
}
|
||||
|
||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||
/// 根据编译环境选择 DTCoreText 或 CoreText 路径。
|
||||
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@ -32,6 +52,11 @@ struct RDEPUBTextLayouter {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - CoreText 分页路径(回退方案)
|
||||
|
||||
/// 使用原生 CoreText API 进行分页。
|
||||
///
|
||||
/// 流程:CTFramesetterCreateFrame → 获取可视范围 → 语义边界调整 → 记录帧
|
||||
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
|
||||
return []
|
||||
@ -49,7 +74,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: frame)
|
||||
@ -89,7 +114,10 @@ struct RDEPUBTextLayouter {
|
||||
return frames
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 分页路径(首选方案)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||
@ -152,6 +180,18 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 语义边界调整
|
||||
|
||||
/// 对 CoreText 提出的分页范围进行语义边界调整。
|
||||
///
|
||||
/// 调整优先级:
|
||||
/// 1. 若已达章节末尾,直接返回 chapterEnd
|
||||
/// 2. 优先在语义边界(pageBreakBefore/After)分页
|
||||
/// 3. 其次在 pageRelate 跨页关联点分页
|
||||
/// 4. 再次在附件边界分页(块级附件需整体移动)
|
||||
/// 5. 以上都不满足时,使用原始帧限制分页
|
||||
///
|
||||
/// 最小分页长度约束:不低于原始范围的 55%,避免单页内容过少。
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int,
|
||||
@ -193,6 +233,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 最小分页长度:原始范围的 55%
|
||||
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
|
||||
let minimumEnd = proposedRange.location + minLength
|
||||
|
||||
@ -203,6 +244,7 @@ struct RDEPUBTextLayouter {
|
||||
let currentSemanticHints = proposedSemanticHints
|
||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||
|
||||
// 1. 语义边界(pageBreakBefore/After)
|
||||
if let semanticBoundary = preferredSemanticBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@ -230,6 +272,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. pageRelate 跨页关联边界
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: minimumEnd,
|
||||
@ -258,6 +301,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 附件边界(块级附件整体移动)
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@ -284,6 +328,7 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// 4. 帧限制(默认分页)
|
||||
return (
|
||||
range: proposedRange,
|
||||
breakReason: .frameLimit,
|
||||
@ -305,6 +350,11 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
///
|
||||
/// 只有当边界位置超过 minimumEnd(最小分页长度约束)时才有效。
|
||||
private func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int
|
||||
@ -337,6 +387,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
||||
/// 行内脚注图标等不应导致整段移动。
|
||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
@ -346,9 +398,8 @@ struct RDEPUBTextLayouter {
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// Mirror WXRead more closely: only block-level attachments should
|
||||
// push the entire block to the next page. Inline footnote icons and
|
||||
// other inline attachments must not cause a whole paragraph to move.
|
||||
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
||||
// 行内脚注图标等行内附件不应导致整段移动。
|
||||
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
@ -361,6 +412,8 @@ struct RDEPUBTextLayouter {
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界:当下一页起始是一个 pageRelate 块且
|
||||
/// 该块恰好是当前页最后一行时,将最后一行移到下一页。
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
@ -384,6 +437,9 @@ struct RDEPUBTextLayouter {
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
// MARK: - 属性查询工具
|
||||
|
||||
/// 获取指定位置的块级元素范围
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -393,6 +449,7 @@ struct RDEPUBTextLayouter {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 获取指定位置的块级元素类型
|
||||
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -400,6 +457,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取指定位置的附件布局方式
|
||||
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -407,6 +465,7 @@ struct RDEPUBTextLayouter {
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
/// 获取包含指定位置的段落范围
|
||||
private func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
@ -414,6 +473,7 @@ struct RDEPUBTextLayouter {
|
||||
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
|
||||
}
|
||||
|
||||
/// 获取指定范围内的所有附件字符范围
|
||||
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||
var results: [NSRange] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
|
||||
@ -423,6 +483,7 @@ struct RDEPUBTextLayouter {
|
||||
return results
|
||||
}
|
||||
|
||||
/// 获取指定位置的语义提示列表
|
||||
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@ -432,6 +493,7 @@ struct RDEPUBTextLayouter {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||
@ -445,6 +507,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的块级元素类型列表(去重)
|
||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||
var kinds: [RDEPUBTextBlockKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
||||
@ -458,6 +521,7 @@ struct RDEPUBTextLayouter {
|
||||
return kinds
|
||||
}
|
||||
|
||||
/// 获取指定范围内的语义提示列表(去重)
|
||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||
var hints: [RDEPUBTextSemanticHint] = []
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
||||
@ -469,6 +533,7 @@ struct RDEPUBTextLayouter {
|
||||
return hints
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件布局方式列表(去重)
|
||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
||||
@ -482,6 +547,7 @@ struct RDEPUBTextLayouter {
|
||||
return placements
|
||||
}
|
||||
|
||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||
private func nearestTrailingFragmentID(
|
||||
endingAt location: Int,
|
||||
fragmentOffsets: [String: Int]
|
||||
@ -492,11 +558,11 @@ struct RDEPUBTextLayouter {
|
||||
.key
|
||||
}
|
||||
|
||||
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
|
||||
// MARK: - 行级 avoidPageBreakInside(WXRead 方案)
|
||||
|
||||
/// Scans CTFrame lines backward from the last line, removing trailing lines
|
||||
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
|
||||
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
|
||||
/// 从 CTFrame 最后一行向前扫描,移除落在 avoidPageBreakInside 保护块内的尾部行。
|
||||
/// 对标 WXRead 的 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded。
|
||||
/// 最多移除 3 行(kMaxLinesToRemove),避免因保护块过大导致整页内容被清空。
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@ -509,8 +575,8 @@ struct RDEPUBTextLayouter {
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
// Scan backward: count consecutive trailing lines inside protected blocks
|
||||
// kMaxLinesToRemove = 3 (same as WXRead)
|
||||
// 从最后一行向前扫描,统计落在保护块内的连续尾部行数
|
||||
// kMaxLinesToRemove = 3(与 WXRead 一致)
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
@ -521,8 +587,7 @@ struct RDEPUBTextLayouter {
|
||||
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
// Don't remove more than kMaxLinesToRemove; stop here
|
||||
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
|
||||
// 不超过 kMaxLinesToRemove 行,停在这里
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
@ -535,7 +600,7 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
let validLineCount = lines.count - linesToRemove
|
||||
guard validLineCount > 0 else {
|
||||
// All lines are in protected blocks; fall back to proposed
|
||||
// 所有行都在保护块内,回退到原始范围
|
||||
return proposed
|
||||
}
|
||||
|
||||
@ -549,6 +614,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// CoreText 路径的 keepWithNext 处理:从最后行向前扫描
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
@ -561,7 +627,10 @@ struct RDEPUBTextLayouter {
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 行级处理
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// DTCoreText 路径的 avoidPageBreakInside 处理
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@ -603,6 +672,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// DTCoreText 路径的 keepWithNext 处理
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
@ -615,6 +685,8 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 从最后行向前扫描,移除落在 keepWithNext 保护块内的尾部行。
|
||||
/// 最多移除 3 行。
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
@ -648,7 +720,7 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
/// 检查某行是否落在 avoidPageBreakInside 保护块内
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@ -665,6 +737,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 检查某行是否落在 keepWithNext 保护块内
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||
@ -681,6 +754,7 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
@ -690,6 +764,7 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
@ -698,6 +773,9 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 诊断日志
|
||||
|
||||
/// 生成分页诊断日志,记录分页原因、范围和语义信息
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
// MARK: - NSAttributedString 分页扩展
|
||||
|
||||
/// 为 NSAttributedString 提供便捷的分页方法,是渲染链路中 CoreText 分页的入口点。
|
||||
extension NSAttributedString {
|
||||
/// 将富文本按指定页面尺寸分页,返回布局帧列表(每帧对应一页)。
|
||||
///
|
||||
/// 这是 `RDEPUBTextBookBuilder` 和 `RDPlainTextBookBuilder` 调用的核心分页方法。
|
||||
/// - Parameters:
|
||||
/// - size: 页面尺寸
|
||||
/// - fragmentOffsets: fragment ID → 字符偏移量映射(用于阅读位置恢复)
|
||||
/// - config: 布局配置(孤行控制、avoidPageBreakInside 等)
|
||||
/// - Returns: 分页后的布局帧列表
|
||||
func rd_paginatedFrames(
|
||||
size: CGSize,
|
||||
fragmentOffsets: [String: Int] = [:],
|
||||
@ -11,12 +22,17 @@ extension NSAttributedString {
|
||||
.layoutFrames(fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
/// 简化版分页:只返回每页的 NSRange 列表(不含语义元数据)
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
rd_paginatedFrames(size: size).map(\.contentRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor CSS 转换扩展
|
||||
|
||||
/// 为 UIColor 提供 CSS 颜色字符串转换,用于动态生成暗色模式 CSS。
|
||||
extension UIColor {
|
||||
/// 将 UIColor 转换为 CSS rgba() 字符串格式
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Performance Sample
|
||||
// MARK: - 性能采样数据模型
|
||||
|
||||
/// 单个章节的性能采样数据,记录渲染和分页的耗时。
|
||||
public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
/// 章节文件路径
|
||||
public var chapterHref: String
|
||||
/// HTML 渲染耗时(秒)
|
||||
public var renderDuration: TimeInterval
|
||||
/// CoreText 分页耗时(秒)
|
||||
public var paginateDuration: TimeInterval
|
||||
/// 分页后的页数
|
||||
public var pageCount: Int
|
||||
/// 富文本字符长度
|
||||
public var attributedStringLength: Int
|
||||
/// 是否命中分页缓存
|
||||
public var cacheHit: Bool
|
||||
|
||||
public init(
|
||||
@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance Sampler
|
||||
// MARK: - 性能采样器
|
||||
|
||||
/// 书籍构建过程的性能采样器,用于监控每章的渲染和分页耗时。
|
||||
///
|
||||
/// 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点,
|
||||
/// 构建完成后输出汇总报告。
|
||||
public final class RDEPUBTextPerformanceSampler {
|
||||
/// 所有章节的采样数据列表
|
||||
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
|
||||
/// 整本书构建的总耗时(秒)
|
||||
public var totalBuildDuration: TimeInterval = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
public func summary() -> String {
|
||||
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
|
||||
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
|
||||
@ -47,11 +62,13 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
|
||||
}
|
||||
|
||||
/// 重置所有采样数据
|
||||
public func reset() {
|
||||
samples.removeAll()
|
||||
totalBuildDuration = 0
|
||||
}
|
||||
|
||||
/// 将秒转换为毫秒格式字符串
|
||||
private func formatMS(_ duration: TimeInterval) -> String {
|
||||
String(format: "%.0fms", duration * 1000)
|
||||
}
|
||||
|
||||
@ -1,43 +1,71 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 自定义富文本属性键
|
||||
|
||||
/// EPUBTextRendering 层使用的自定义 NSAttributedString 属性键。
|
||||
/// 这些属性由 `RDEPUBTextRendererSupport` 在渲染阶段注入,
|
||||
/// 供 `RDEPUBTextLayouter` 分页时读取语义信息。
|
||||
public extension NSAttributedString.Key {
|
||||
/// 块级元素在富文本中的字符范围(NSString 编码的 NSRange)
|
||||
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
|
||||
/// 块级元素在章节中的序号
|
||||
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
|
||||
/// fragment 锚点 ID
|
||||
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
|
||||
/// 附件类型(图片/通用附件)
|
||||
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
|
||||
/// 块级元素类型(段落/列表/表格/代码等)
|
||||
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
|
||||
/// 语义提示标记(避免分页、保持与下一段同页等,逗号分隔)
|
||||
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
|
||||
/// 附件布局方式(行内/基线/居中)
|
||||
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
|
||||
}
|
||||
|
||||
// MARK: - 块级元素类型枚举
|
||||
|
||||
/// HTML 块级元素的类型分类,用于分页时判断语义边界。
|
||||
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
case paragraph
|
||||
case list
|
||||
case table
|
||||
case code
|
||||
case blockquote
|
||||
case attachment
|
||||
case generic
|
||||
case paragraph // <p>
|
||||
case list // <ul>, <ol>, <li>
|
||||
case table // <table> 系列
|
||||
case code // <pre>, <code>
|
||||
case blockquote // <blockquote>
|
||||
case attachment // 附件块(图片、figure 等)
|
||||
case generic // 通用块(<div>, <h1>-<h6> 等)
|
||||
}
|
||||
|
||||
// MARK: - 语义提示枚举
|
||||
|
||||
/// 分页语义提示,指导分页引擎在何处/何处不进行分页。
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
case avoidPageBreakInside // 禁止在块内分页(如代码块、表格)
|
||||
case keepWithNext // 与下一段保持同页(如标题)
|
||||
case pageBreakBefore // 在此元素前强制分页
|
||||
case pageBreakAfter // 在此元素后强制分页
|
||||
case pageRelate // 微信读书式跨页关联元素
|
||||
}
|
||||
|
||||
// MARK: - 附件布局方式枚举
|
||||
|
||||
/// 富文本附件(图片等)的垂直布局方式。
|
||||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||||
case inline
|
||||
case baseline
|
||||
case centered
|
||||
case inline // 行内布局
|
||||
case baseline // 基线对齐
|
||||
case centered // 垂直居中(块级)
|
||||
}
|
||||
|
||||
// MARK: - 渲染样式
|
||||
|
||||
/// 阅读器渲染样式配置,控制字体、行距、颜色等。
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
/// 基础字体(大小和字族将覆盖 EPUB 原始样式)
|
||||
public var font: UIFont
|
||||
/// 行间距(pt)
|
||||
public var lineSpacing: CGFloat
|
||||
/// 文本颜色(nil 则使用 EPUB 原始颜色)
|
||||
public var textColor: UIColor?
|
||||
/// 背景颜色(nil 则透明,用于暗色模式判断)
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
@ -48,10 +76,17 @@ public struct RDEPUBTextRenderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 布局配置
|
||||
|
||||
/// 分页引擎的布局控制参数。
|
||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
||||
public var avoidOrphans: Bool
|
||||
/// 是否避免寡行(段落第一行单独在上一页底部)
|
||||
public var avoidWidows: Bool
|
||||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||||
public var avoidPageBreakInsideEnabled: Bool
|
||||
/// 图片最大高度占页面高度的比例
|
||||
public var imageMaxHeightRatio: CGFloat
|
||||
|
||||
public init(
|
||||
@ -66,17 +101,22 @@ public struct RDEPUBTextLayoutConfig: Equatable {
|
||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||
}
|
||||
|
||||
/// 默认配置
|
||||
public static let `default` = RDEPUBTextLayoutConfig()
|
||||
}
|
||||
|
||||
// MARK: - CSS 样式表层级
|
||||
|
||||
/// 样式表层级类型,用于 CSS 层叠优先级管理。
|
||||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||||
case `default`
|
||||
case replace
|
||||
case dark
|
||||
case epub
|
||||
case user
|
||||
case `default` // 基础重置样式(margin、padding 等)
|
||||
case replace // 元素替换样式(图片居中、标题分页等)
|
||||
case dark // 暗色模式覆盖
|
||||
case epub // EPUB 原始样式表
|
||||
case user // 用户自定义样式(字号、行距、颜色等)
|
||||
}
|
||||
|
||||
/// 单个 CSS 样式表层,包含层级类型和 CSS 内容。
|
||||
public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
public var kind: RDEPUBTextStyleSheetLayerKind
|
||||
public var css: String
|
||||
@ -87,6 +127,9 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
|
||||
///
|
||||
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
|
||||
public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
public var layers: [RDEPUBTextStyleSheetLayer]
|
||||
|
||||
@ -94,6 +137,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
/// 合并所有非空层的 CSS,每层添加注释头标记
|
||||
public var combinedCSS: String {
|
||||
layers
|
||||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
@ -104,11 +148,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 资源引用诊断
|
||||
|
||||
/// 资源引用类型(样式表或图片)
|
||||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||||
case stylesheet
|
||||
case image
|
||||
}
|
||||
|
||||
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
|
||||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
public var kind: RDEPUBTextResourceReferenceKind
|
||||
public var chapterHref: String
|
||||
@ -134,6 +182,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 章节渲染上下文与请求
|
||||
|
||||
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
|
||||
public struct RDEPUBTextChapterContext: Equatable {
|
||||
public var href: String
|
||||
public var title: String
|
||||
@ -159,6 +210,7 @@ public struct RDEPUBTextChapterContext: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
|
||||
public struct RDEPUBTextChapterRenderRequest {
|
||||
public var context: RDEPUBTextChapterContext
|
||||
public var style: RDEPUBTextRenderStyle
|
||||
@ -169,9 +221,15 @@ public struct RDEPUBTextChapterRenderRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染结果
|
||||
|
||||
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
/// 渲染后的富文本
|
||||
public var attributedString: NSAttributedString
|
||||
/// fragment ID → 字符偏移量映射
|
||||
public var fragmentOffsets: [String: Int]
|
||||
/// 资源引用诊断列表
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
|
||||
public init(
|
||||
@ -185,11 +243,18 @@ public struct RDEPUBRenderedChapterContent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染器协议
|
||||
|
||||
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
|
||||
///
|
||||
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
|
||||
public protocol RDEPUBTextRenderer {
|
||||
/// 渲染单个章节
|
||||
func renderChapter(
|
||||
request: RDEPUBTextChapterRenderRequest
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
|
||||
/// 便捷方法:直接渲染 HTML 字符串
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
@ -197,6 +262,7 @@ public protocol RDEPUBTextRenderer {
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
/// 协议默认实现:将便捷方法委托给完整方法
|
||||
public extension RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
@ -215,9 +281,12 @@ public extension RDEPUBTextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 渲染错误
|
||||
|
||||
/// EPUB 文本渲染过程中可能出现的错误类型。
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
|
||||
case htmlImportFailed // HTML 富文本解析失败
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@ -4,13 +4,36 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// 渲染器支持工具集:负责 HTML 预处理、fragment 标记注入/提取、CSS 组装和字体标准化。
|
||||
///
|
||||
/// 本枚举是渲染链路的预处理层,`RDEPUBTextBookBuilder` 在调用渲染器前通过
|
||||
/// `makeChapterRenderRequest()` 完成 HTML 的全部预处理工作。
|
||||
///
|
||||
/// 核心职责:
|
||||
/// - HTML 规范化:清理冗余字符、规范化附件标记
|
||||
/// - Fragment 标记注入/提取:id 属性 → ${id=xxx} 标记 → 字符偏移量映射
|
||||
/// - 语义标记注入:HTML 标签 → ${rd-sem-start/end} 标记 → NSAttributedString 属性
|
||||
/// - CSS 组装:按优先级合并 default/replace/dark/epub/user 五层样式表
|
||||
/// - 字体标准化:将 EPUB 原始字体映射到用户设置的字体
|
||||
/// - 附件规范化:图片尺寸、脚注图标、封面图片等特殊处理
|
||||
enum RDEPUBTextRendererSupport {
|
||||
/// 外部样式表链接正则
|
||||
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 图片源地址正则
|
||||
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
|
||||
/// 语义标记正则(${rd-sem-start:...} / ${rd-sem-end:...})
|
||||
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
|
||||
/// 脚注附件日志已输出标记(避免重复日志)
|
||||
private static var didLogFootnoteAttachment = false
|
||||
/// 封面附件日志已输出标记(避免重复日志)
|
||||
private static var didLogCoverAttachment = false
|
||||
|
||||
// MARK: - Fragment 标记注入/提取
|
||||
|
||||
/// 将 HTML 中的 id 属性元素注入 fragment 标记。
|
||||
///
|
||||
/// 将 `<tag id="xxx" ...>` 转换为 `${id=xxx}<tag id="xxx" ...>`,
|
||||
/// 使 DTCoreText 渲染后能在富文本中定位到 fragment 偏移量。
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从渲染后的富文本中提取 fragment 偏移量映射。
|
||||
///
|
||||
/// 扫描 `${id=xxx}` 标记,记录每个 fragment ID 的字符偏移量,
|
||||
/// 然后删除标记文本,返回 [fragmentID: offset] 字典。
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
/// 将 HTML 中注入的语义标记(${rd-sem-start/end})应用到富文本属性中。
|
||||
///
|
||||
/// 标记包含 blockKind、semanticHints、attachmentPlacement 等信息,
|
||||
/// 解析后写入对应的 .rdPage* 属性键,供分页引擎读取。
|
||||
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
|
||||
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
|
||||
return
|
||||
@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
///
|
||||
/// 遍历富文本的所有属性区间:
|
||||
/// 1. 将 EPUB 原始字体映射到用户设置字体(保留粗体/斜体特征)
|
||||
/// 2. 统一行距和段间距
|
||||
/// 3. 应用用户设置的文本颜色
|
||||
/// 4. 规范化附件显示尺寸
|
||||
/// 5. 注入块级元素范围、序号、类型等分页语义属性
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退渲染:当 DTCoreText 不可用时,将 HTML 源码当作纯文本处理。
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
@ -137,6 +177,16 @@ enum RDEPUBTextRendererSupport {
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
/// 核心预处理方法:将原始 HTML 转换为完整的章节渲染请求。
|
||||
///
|
||||
/// 预处理流程:
|
||||
/// 1. normalizeHTML:清理冗余字符、规范化附件 HTML 标记
|
||||
/// 2. injectPaginationSemanticMarkers:为 HTML 标签注入语义标记
|
||||
/// 3. inlineLinkedStyleSheets:将外部样式表内联到 HTML 中
|
||||
/// 4. 组装五层 CSS(default/replace/dark/epub/user)并注入 style 标签
|
||||
/// 5. injectBaseHref:注入 base 标签以解析相对路径
|
||||
/// 6. injectFragmentMarkers:注入 fragment 锚点标记
|
||||
/// 7. collectImageDiagnostics:收集图片资源引用诊断
|
||||
static func makeChapterRenderRequest(
|
||||
href: String,
|
||||
title: String,
|
||||
@ -197,6 +247,7 @@ enum RDEPUBTextRendererSupport {
|
||||
return RDEPUBTextChapterRenderRequest(context: context, style: style)
|
||||
}
|
||||
|
||||
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
||||
static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。
|
||||
///
|
||||
/// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容,
|
||||
/// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。
|
||||
/// 每个搜索结果包含进度位置、预览文本和语义锚点。
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
/// 在全书中搜索关键词,返回所有匹配结果。
|
||||
///
|
||||
/// 搜索流程:
|
||||
/// 1. 遍历所有章节
|
||||
/// 2. 在每章的富文本字符串中执行大小写不敏感搜索
|
||||
/// 3. 计算匹配位置的阅读进度(progression)
|
||||
/// 4. 生成预览文本(匹配位置前后各 12 个字符)
|
||||
/// 5. 生成语义锚点(用于跨设备/跨字体精确定位)
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
return matches
|
||||
}
|
||||
|
||||
/// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import UIKit
|
||||
|
||||
/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线
|
||||
/// 纯文本书籍构建器:将 TXT 文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线。
|
||||
///
|
||||
/// 本类将纯文本文件(.txt)转换为与 EPUB 相同的分页书籍模型,
|
||||
/// 使得纯文本阅读器可以复用 EPUBReader 的分页、导航、搜索等全部功能。
|
||||
///
|
||||
/// 处理流程:
|
||||
/// 1. 解码文本文件(UTF-8 → GB18030 → GBK 兼容编码)
|
||||
/// 2. 按中文章节正则拆分为多个章节
|
||||
/// 3. 将纯文本包装为 HTML 段落
|
||||
/// 4. 通过渲染器(DTCoreText)渲染为富文本
|
||||
/// 5. CoreText 分页 → RDEPUBTextBook
|
||||
public final class RDPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
@ -8,6 +18,13 @@ public final class RDPlainTextBookBuilder {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
/// 从纯文本文件构建分页书籍。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - textFileURL: 纯文本文件的本地 URL
|
||||
/// - pageSize: 页面尺寸
|
||||
/// - style: 渲染样式(字体、行距、颜色)
|
||||
/// - Returns: 分页后的书籍模型
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
@ -86,12 +103,14 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
/// 章节规格:标题 + 内容
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。无匹配时整体作为一章。
|
||||
/// 按中文章节正则拆分文本。匹配 "第X章/节/回/卷" 格式,
|
||||
/// 无匹配时将整篇文本作为一章处理。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
@ -126,7 +145,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落
|
||||
/// 将纯文本包装为 HTML 段落:每行一个 `<p>` 标签
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
@ -135,6 +154,7 @@ public final class RDPlainTextBookBuilder {
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
/// 解码文本文件,按优先级尝试 UTF-8 → GB18030 → GBK 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
|
||||
@ -4,6 +4,11 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面交互控制器
|
||||
|
||||
/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑
|
||||
/// 在 DTCoreText 排版结果和用户交互之间充当桥梁
|
||||
/// 提供字符索引查找、选择范围计算、选择矩形计算等功能
|
||||
final class RDEPUBPageInteractionController {
|
||||
|
||||
var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
@ -4,29 +4,56 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面排版快照数据结构
|
||||
|
||||
/// 文本行信息
|
||||
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
|
||||
struct RDEPUBPageLine {
|
||||
/// 行内文本在 chapterContent 中的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// 行的框架(相对于排版区域)
|
||||
let frame: CGRect
|
||||
/// 行的基线原点
|
||||
let baselineOrigin: CGPoint
|
||||
/// 基线以上的高度(升部)
|
||||
let ascent: CGFloat
|
||||
/// 基线以下的高度(降部)
|
||||
let descent: CGFloat
|
||||
/// 行间距
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
/// 文本 Run 信息
|
||||
/// 描述一行内某个连续绘制单元(如普通文字或附件)
|
||||
struct RDEPUBPageRun {
|
||||
/// Run 内文本的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// Run 的绘制框架
|
||||
let frame: CGRect
|
||||
/// 是否为附件(图片等)
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
/// 附件信息
|
||||
/// 描述嵌入在文本中的图片或其他附件
|
||||
struct RDEPUBPageAttachment {
|
||||
/// 附件在文本中的绝对字符范围
|
||||
let stringRange: NSRange
|
||||
/// 附件的显示框架
|
||||
let frame: CGRect
|
||||
/// 附件的建议显示尺寸
|
||||
let displaySize: CGSize
|
||||
/// 附件的布局方式(行内/浮动等)
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
/// 附件类型(封面图/普通图片等)
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
// MARK: - 页面排版快照
|
||||
|
||||
/// 页面排版快照
|
||||
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
|
||||
/// 用于支持文本选择、高亮渲染和搜索结果定位
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
let page: RDEPUBTextPage
|
||||
let lines: [RDEPUBPageLine]
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 底部工具栏
|
||||
|
||||
/// 阅读器底部工具栏
|
||||
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
|
||||
/// 按钮可见性可根据 configuration 动态调整
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 点击目录按钮回调
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
/// 点击书签列表按钮回调
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
/// 点击高亮列表按钮回调
|
||||
var onShowHighlights: (() -> Void)?
|
||||
/// 点击新建标注按钮回调
|
||||
var onAddHighlight: (() -> Void)?
|
||||
/// 点击设置按钮回调
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
/// 水平等分布局的按钮容器
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
@ -16,10 +31,15 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
return view
|
||||
}()
|
||||
|
||||
/// 目录按钮
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 书签列表按钮
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 高亮列表按钮
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 新建标注按钮
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 设置按钮
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -78,6 +98,9 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 按钮状态控制
|
||||
|
||||
/// 设置新建标注按钮是否可用(有选中文本时可用)
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 目录面板
|
||||
|
||||
/// EPUB 阅读器的目录面板控制器
|
||||
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
/// 用户选择目录项时的回调
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
|
||||
@ -1,24 +1,75 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文本渲染引擎
|
||||
|
||||
/// EPUB 文本渲染引擎枚举
|
||||
/// 定义了当前支持的文本渲染方式,未来可扩展为多引擎选择
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
/// 基于 DTCoreText 的渲染引擎,将 HTML/CSS 转换为 NSAttributedString
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
// MARK: - 阅读器配置
|
||||
|
||||
/// EPUB 阅读器的完整配置结构体
|
||||
/// 作为 EPUBUI 层的入口配置,控制阅读器的外观、行为和功能开关
|
||||
/// 调用方在创建 RDEPUBReaderController 前可自定义此配置
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
// MARK: 阅读排版参数
|
||||
|
||||
/// 正文字号(单位:pt),默认 15
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读)
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
/// 横屏时是否启用双页显示,默认开启
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
|
||||
// MARK: UI 功能开关
|
||||
|
||||
/// 是否显示目录入口,默认开启
|
||||
public var showsTableOfContents: Bool
|
||||
/// 是否允许文本高亮功能,默认开启
|
||||
public var allowsHighlights: Bool
|
||||
/// 是否显示设置面板入口,默认开启
|
||||
public var showsSettingsPanel: Bool
|
||||
|
||||
// MARK: 内容内边距
|
||||
|
||||
/// 流式排版(reflowable)内容的内边距,上下 40pt、左右 16pt
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定布局(fixed layout)内容的内边距,默认为零
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
|
||||
// MARK: 主题与布局
|
||||
|
||||
/// 当前阅读主题(浅色/深色/护眼等),默认 .light
|
||||
public var theme: RDEPUBReaderTheme
|
||||
/// 固定布局的适配模式:按页适配或按宽度适配
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定布局的跨页模式:自动/单页/双页
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 文本渲染引擎,默认使用 DTCoreText
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
|
||||
// MARK: 初始化
|
||||
|
||||
/// 创建阅读器配置,所有参数均提供合理的默认值
|
||||
/// - Parameters:
|
||||
/// - fontSize: 正文字号,默认 15pt
|
||||
/// - lineHeightMultiple: 行距倍数,默认 1.6
|
||||
/// - displayType: 翻页模式,默认 .pageCurl
|
||||
/// - landscapeDualPageEnabled: 横屏双页,默认 true
|
||||
/// - showsTableOfContents: 显示目录入口,默认 true
|
||||
/// - allowsHighlights: 允许高亮,默认 true
|
||||
/// - showsSettingsPanel: 显示设置面板,默认 true
|
||||
/// - reflowableContentInsets: 流式排版内边距
|
||||
/// - fixedContentInset: 固定布局内边距
|
||||
/// - theme: 阅读主题,默认 .light
|
||||
/// - fixedLayoutFit: 固定布局适配模式
|
||||
/// - fixedLayoutSpreadMode: 固定布局跨页模式
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@ -49,10 +100,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
}
|
||||
|
||||
/// 默认配置实例,使用所有参数的默认值
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
// MARK: - 配置转换
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
/// 将用户可见的配置转换为底层排版引擎所需的 RDEPUBPreferences
|
||||
/// - Returns: 排版偏好设置实例,供 Core 层的渲染管道使用
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
|
||||
@ -1,6 +1,29 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文件说明
|
||||
|
||||
/// EPUB 阅读器主控制器
|
||||
/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验
|
||||
/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面
|
||||
///
|
||||
/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation
|
||||
///
|
||||
/// **核心职责:**
|
||||
/// - EPUB 解析与分页(支持流式排版和固定布局)
|
||||
/// - 阅读位置的持久化与恢复
|
||||
/// - 高亮、书签的增删改查
|
||||
/// - 工具栏、目录、设置等 UI 面板的协调管理
|
||||
/// - 搜索功能(全文搜索、前后跳转)
|
||||
///
|
||||
/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation
|
||||
|
||||
// MARK: - 阅读器主控制器
|
||||
|
||||
public final class RDEPUBReaderController: UIViewController {
|
||||
// MARK: - 内部辅助类型
|
||||
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
private struct RDEPUBViewportSignature: Equatable {
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
@ -19,6 +42,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
private enum RDEPUBViewportChangeReason {
|
||||
case viewLayout
|
||||
case orientationTransition
|
||||
@ -27,8 +51,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
// MARK: - 公开属性
|
||||
|
||||
/// 阅读器委托,用于接收阅读状态变更通知
|
||||
public weak var delegate: RDEPUBReaderDelegate?
|
||||
|
||||
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
persistReaderSettingsIfNeeded()
|
||||
@ -54,38 +82,49 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前阅读位置
|
||||
public var currentLocation: RDEPUBLocation? {
|
||||
currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 当前页码(从 1 开始),nil 表示尚未加载
|
||||
public var currentPageNumber: Int? {
|
||||
guard readerView.currentPage >= 0 else { return nil }
|
||||
return readerView.currentPage + 1
|
||||
}
|
||||
|
||||
/// 当前用户选中的文本(只读),用于创建高亮/批注
|
||||
public private(set) var currentSelection: RDEPUBSelection?
|
||||
|
||||
/// 所有高亮标注
|
||||
public var highlights: [RDEPUBHighlight] {
|
||||
activeHighlights
|
||||
}
|
||||
|
||||
/// 所有书签
|
||||
public var bookmarks: [RDEPUBBookmark] {
|
||||
activeBookmarks
|
||||
}
|
||||
|
||||
/// 原始目录树结构
|
||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
||||
publication?.tableOfContents ?? []
|
||||
}
|
||||
|
||||
/// 展平后的目录列表(用于目录面板展示,每个条目附带页码)
|
||||
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
|
||||
guard let publication else { return [] }
|
||||
return flattenedTableOfContentsItems(from: publication.tableOfContents)
|
||||
}
|
||||
|
||||
/// 当前阅读位置所在的目录项
|
||||
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
|
||||
resolvedCurrentTableOfContentsItem()
|
||||
}
|
||||
|
||||
// MARK: - 私有属性
|
||||
|
||||
/// EPUB 文件 URL
|
||||
private let epubURL: URL
|
||||
fileprivate let persistence: RDEPUBReaderPersistence?
|
||||
private let readerView = RDReaderView()
|
||||
@ -131,6 +170,14 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
/// 外部 TextBook 的原始文件 URL(用于重建 TextBook)
|
||||
private var textFileURL: URL?
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
/// 使用 EPUB 文件 URL 初始化阅读器
|
||||
/// 会自动从持久化存储中恢复用户的阅读偏好设置
|
||||
/// - Parameters:
|
||||
/// - epubURL: EPUB 文件的本地 URL
|
||||
/// - configuration: 阅读器配置,默认使用 .default
|
||||
/// - persistence: 持久化策略,默认使用 UserDefaults
|
||||
public init(
|
||||
epubURL: URL,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
@ -149,7 +196,15 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程(用于 TXT 等纯文本文件)
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||
/// - Parameters:
|
||||
/// - textBook: 已分页的 TextBook 实例
|
||||
/// - bookIdentifier: 书籍唯一标识符,用于持久化
|
||||
/// - title: 书籍标题
|
||||
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
|
||||
/// - configuration: 阅读器配置
|
||||
/// - persistence: 持久化策略
|
||||
public convenience init(
|
||||
textBook: RDEPUBTextBook,
|
||||
bookIdentifier: String,
|
||||
@ -244,6 +299,9 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 公开 API
|
||||
|
||||
/// 重新加载书籍,重置所有状态并重新解析 EPUB
|
||||
public func reloadBook() {
|
||||
didStartInitialLoad = false
|
||||
parser = nil
|
||||
@ -263,11 +321,18 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameter location: 目标阅读位置
|
||||
public func go(to location: RDEPUBLocation) {
|
||||
guard publication != nil else { return }
|
||||
restoreReadingLocation(location)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard pageNumber > 0 else { return false }
|
||||
@ -289,6 +354,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清除当前文本选中状态
|
||||
public func clearSelection() {
|
||||
updateCurrentSelection(nil)
|
||||
}
|
||||
@ -321,6 +387,12 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 添加高亮标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - color: 高亮颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@ -1285,6 +1357,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
@ -1456,6 +1530,10 @@ extension RDEPUBReaderController {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
// MARK: - 搜索功能
|
||||
|
||||
/// 执行全文搜索,自动跳转到第一个匹配项
|
||||
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
|
||||
public func search(keyword: String) {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
@ -1622,6 +1700,8 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
@ -1724,6 +1804,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
guard readerView.currentPage >= 0,
|
||||
@ -1780,6 +1862,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||||
|
||||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||||
guard let selection else {
|
||||
@ -1902,6 +1986,8 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
|
||||
@ -1,20 +1,86 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器委托协议
|
||||
|
||||
/// EPUB 阅读器的委托协议
|
||||
/// 用于向调用方通知阅读器的各种状态变更事件
|
||||
/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
/// 阅读器成功打开书籍时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - publication: 解析后的出版物对象
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
|
||||
/// 阅读位置更新时调用(翻页、跳转等)
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - location: 新的阅读位置
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
|
||||
/// 阅读器翻到书籍末尾时调用
|
||||
/// - Parameter reader: 阅读器控制器
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
|
||||
/// 用户选中文本发生变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - selection: 当前选中文本,nil 表示取消选中
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
|
||||
/// 高亮标注列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - highlights: 当前所有高亮
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
|
||||
/// 书签列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - bookmarks: 当前所有书签
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
|
||||
/// 搜索结果更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - result: 搜索结果摘要,nil 表示无搜索
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
|
||||
/// 当前搜索匹配项变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - match: 当前匹配项,nil 表示无匹配
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
|
||||
/// 当前所在目录项更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - item: 当前目录项
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
|
||||
/// 用户点击外部链接时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - error: 错误对象
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
|
||||
/// 允许调用方自定义顶部工具栏
|
||||
/// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - topToolView: 顶部工具栏视图
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
// MARK: - 默认空实现
|
||||
|
||||
/// 所有委托方法的默认空实现,调用方可只实现需要的方法
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 高亮管理面板
|
||||
|
||||
/// EPUB 阅读器的高亮标注管理控制器
|
||||
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个高亮位置时的回调
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户编辑高亮批注后的回调
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户删除高亮时的回调
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
/// 高亮列表(按创建时间降序排列)
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
/// 当前主题配置
|
||||
private let theme: RDEPUBReaderTheme
|
||||
/// 提供章节标题的回调,用于显示高亮所在的章节名
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
/// 筛选控件:全部、批注、高亮
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
@ -205,8 +216,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理面板
|
||||
|
||||
/// EPUB 阅读器的书签管理控制器
|
||||
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个书签位置时的回调
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
/// 用户删除书签时的回调
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
|
||||
@ -1,16 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 持久化协议
|
||||
|
||||
/// 阅读器持久化协议
|
||||
/// 定义了阅读位置、书签、高亮和用户设置的存储接口
|
||||
/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等)
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
/// 加载指定书籍的上次阅读位置
|
||||
/// - Parameter bookIdentifier: 书籍唯一标识符
|
||||
/// - Returns: 保存的阅读位置,nil 表示首次打开
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
|
||||
/// 保存指定书籍的当前阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 阅读位置
|
||||
/// - bookIdentifier: 书籍唯一标识符
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有书签
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
|
||||
/// 保存指定书籍的所有书签
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有高亮标注
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
|
||||
/// 保存指定书籍的所有高亮标注
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
|
||||
/// 加载全局阅读器用户设置(字号、行距、主题等)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
|
||||
/// 保存全局阅读器用户设置
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
@ -31,13 +59,30 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults 持久化实现
|
||||
|
||||
/// 基于 UserDefaults 的持久化实现
|
||||
/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置
|
||||
/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
/// UserDefaults 实例
|
||||
private let defaults: UserDefaults
|
||||
/// 阅读位置的 key 前缀
|
||||
private let locationPrefix: String
|
||||
/// 书签列表的 key 前缀
|
||||
private let bookmarksPrefix: String
|
||||
/// 高亮列表的 key 前缀
|
||||
private let highlightsPrefix: String
|
||||
/// 用户设置的 key
|
||||
private let settingsKey: String
|
||||
|
||||
/// 初始化 UserDefaults 持久化策略
|
||||
/// - Parameters:
|
||||
/// - defaults: UserDefaults 实例,默认 .standard
|
||||
/// - locationPrefix: 阅读位置 key 前缀
|
||||
/// - bookmarksPrefix: 书签 key 前缀
|
||||
/// - highlightsPrefix: 高亮 key 前缀
|
||||
/// - settingsKey: 用户设置 key
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 翻页显示模式
|
||||
|
||||
/// 阅读器翻页显示模式枚举
|
||||
/// 用于持久化存储用户的翻页偏好设置,与 RDReaderView.DisplayType 相互转换
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
/// 仿真翻页模式(纸张翻转效果)
|
||||
case pageCurl
|
||||
/// 水平滚动模式
|
||||
case horizontalScroll
|
||||
/// 垂直滚动模式
|
||||
case verticalScroll
|
||||
/// 水平覆盖式滚动模式(类似电子杂志翻页效果)
|
||||
case horizontalCoverScroll
|
||||
|
||||
/// 从底层 DisplayType 转换为 UI 层的 DisplayMode
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
@ -17,6 +26,8 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为底层 RDReaderView 所需的 DisplayType
|
||||
/// 注意:horizontalCoverScroll 会被映射为 horizontalScroll
|
||||
var displayType: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
@ -29,14 +40,25 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主题预设
|
||||
|
||||
/// 阅读器主题预设枚举
|
||||
/// 将 RDEPUBReaderTheme 映射为可序列化的枚举值,用于持久化存储和设置面板展示
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
/// 浅色主题
|
||||
case light
|
||||
/// 黄色护眼主题
|
||||
case yellow
|
||||
/// 绿色护眼主题
|
||||
case green
|
||||
/// 粉色主题
|
||||
case pink
|
||||
/// 蓝色主题
|
||||
case blue
|
||||
/// 深色/夜间主题
|
||||
case dark
|
||||
|
||||
/// 将预设枚举转换为主题配置实例
|
||||
public var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light:
|
||||
@ -54,6 +76,8 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从主题配置实例反向查找对应的预设枚举
|
||||
/// 自定义主题(非内置预设)会返回 nil
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
@ -74,13 +98,26 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 阅读器用户设置
|
||||
|
||||
/// 阅读器的可持久化用户设置
|
||||
/// 用于保存用户在设置面板中调整的阅读偏好(字号、行距、翻页模式、主题等)
|
||||
/// 所有属性均为可选类型,只覆盖用户实际修改过的配置项
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
// MARK: 可调参数
|
||||
|
||||
/// 屏幕亮度(0.0 ~ 1.0),nil 表示使用系统亮度
|
||||
public var brightness: CGFloat?
|
||||
/// 用户选择的正文字号(pt),nil 表示使用默认值
|
||||
public var fontSize: CGFloat?
|
||||
/// 用户选择的行距倍数,nil 表示使用默认值
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
/// 用户选择的翻页模式,nil 表示使用默认值
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
/// 用户选择的主题预设,nil 表示使用默认值
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
/// 初始化用户设置,所有参数默认为 nil
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
@ -95,6 +132,10 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
/// 将用户设置覆盖到基础配置上,得到最终生效的阅读器配置
|
||||
/// nil 属性不会覆盖基础配置的对应值
|
||||
/// - Parameter configuration: 基础配置(通常来自 RDEPUBReaderConfiguration.default)
|
||||
/// - Returns: 合并后的完整配置
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
@ -114,6 +155,12 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
/// 从当前配置和亮度值捕获一份完整的用户设置快照
|
||||
/// 亮度值会被限制在 0.0 ~ 1.0 范围内
|
||||
/// - Parameters:
|
||||
/// - configuration: 当前生效的阅读器配置
|
||||
/// - brightness: 当前屏幕亮度
|
||||
/// - Returns: 完整的用户设置实例
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 设置面板
|
||||
|
||||
/// EPUB 阅读器的设置面板控制器
|
||||
/// 提供亮度、字号、行距、翻页方式、主题的调节功能
|
||||
/// 支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 亮度变化回调(0.0 ~ 1.0)
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
/// 字号变化回调(pt 值)
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
/// 行距倍数变化回调
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
/// 翻页模式变化回调
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
/// 主题变化回调
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
// MARK: 主题预设枚举
|
||||
|
||||
/// 设置面板中的主题预设枚举,用于按钮显示和主题切换
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
@ -60,7 +75,9 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
}()
|
||||
private var themeButtons: [UIButton] = []
|
||||
|
||||
/// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9)
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
/// 当前配置的本地副本,修改后通过回调通知控制器
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 目录条目
|
||||
|
||||
/// 展平后的目录条目结构体
|
||||
/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
/// 章节标题
|
||||
public var title: String
|
||||
/// 章节的 href(相对于 EPUB 容器根目录)
|
||||
public var href: String
|
||||
/// 目录层级深度(0 为顶级章节),用于列表缩进显示
|
||||
public var depth: Int
|
||||
/// 该章节所在页码(从 1 开始),nil 表示无法确定
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
|
||||
@ -1,13 +1,37 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器主题
|
||||
|
||||
/// EPUB 阅读器主题配置结构体
|
||||
/// 定义阅读器内容区域和工具栏的颜色方案
|
||||
/// 内置浅色、深色、黄色、绿色、粉色、蓝色六套预设主题
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
// MARK: 内容区颜色
|
||||
|
||||
/// 内容区域的背景色
|
||||
public var contentBackgroundColor: UIColor
|
||||
/// 内容区域的正文文字颜色
|
||||
public var contentTextColor: UIColor
|
||||
|
||||
// MARK: 工具栏颜色
|
||||
|
||||
/// 工具栏背景色
|
||||
public var toolBackgroundColor: UIColor
|
||||
/// 工具栏控件文字/图标颜色
|
||||
public var toolControlTextColor: UIColor
|
||||
/// 工具栏控件未选中状态的边框颜色
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
/// 工具栏中分隔线的颜色
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
/// 初始化主题配置
|
||||
/// - Parameters:
|
||||
/// - contentBackgroundColor: 内容区背景色
|
||||
/// - contentTextColor: 内容区文字颜色
|
||||
/// - toolBackgroundColor: 工具栏背景色
|
||||
/// - toolControlTextColor: 工具栏控件颜色
|
||||
/// - toolControlBorderUnselectColor: 控件未选中边框色
|
||||
/// - toolLineColor: 分隔线颜色
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
@ -24,6 +48,9 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
// MARK: 内置预设主题
|
||||
|
||||
/// 浅色主题:白底黑字,适合日间阅读
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
@ -33,6 +60,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 深色主题:黑底白字,适合夜间阅读
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
@ -42,6 +70,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 黄色护眼主题:暖色调背景,减少蓝光刺激
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -51,6 +80,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 绿色护眼主题:模拟纸质书的柔和绿色背景
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -60,6 +90,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 粉色主题:柔和粉色背景
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -69,6 +100,7 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 蓝色主题:冷色调蓝色背景
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@ -79,11 +111,15 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CSS 颜色转换
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
/// 将内容区背景色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
}
|
||||
|
||||
/// 将内容区文字颜色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 工具栏基类
|
||||
|
||||
/// 阅读器工具栏的基类
|
||||
/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
/// 分隔线视图
|
||||
private let lineView = UIView()
|
||||
/// 分隔线高度
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -19,15 +25,24 @@ open class RDEPUBReaderToolView: UIView {
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
/// 应用主题配色到工具栏
|
||||
/// - Parameter theme: 阅读器主题配置
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
/// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部)
|
||||
/// - Parameter bounds: 工具栏的 bounds
|
||||
/// - Returns: 分隔线的 frame
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具栏按钮
|
||||
|
||||
/// 阅读器工具栏专用按钮
|
||||
/// 继承自 UIButton,用于工具栏中的功能按钮
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 顶部工具栏
|
||||
|
||||
/// 阅读器顶部工具栏
|
||||
/// 显示书名、返回按钮和书签按钮
|
||||
/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
/// 返回按钮点击回调
|
||||
var onBack: (() -> Void)?
|
||||
/// 书签按钮点击回调
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
@ -1,21 +1,41 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 覆盖层装饰类型
|
||||
|
||||
/// 覆盖层装饰的数据结构
|
||||
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
/// 装饰类型枚举
|
||||
enum Kind: String {
|
||||
/// 文本选区(蓝色半透明)
|
||||
case selection
|
||||
/// 用户高亮标注
|
||||
case highlight
|
||||
/// 用户划线标注
|
||||
case underline
|
||||
/// 搜索匹配项(普通)
|
||||
case search
|
||||
/// 搜索匹配项(当前高亮)
|
||||
case activeSearch
|
||||
/// 定位指示(跳转到位置时的动画目标)
|
||||
case locate
|
||||
}
|
||||
|
||||
/// 装饰类型
|
||||
var kind: Kind
|
||||
/// 装饰对应的绝对文本范围
|
||||
var absoluteRange: NSRange
|
||||
/// 装饰的绘制矩形数组(每行一个矩形)
|
||||
var rects: [CGRect]
|
||||
/// 装饰的颜色
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
// MARK: - 选择覆盖层视图
|
||||
|
||||
/// 文本选择和装饰的覆盖层绘制视图
|
||||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||||
final class RDEPUBSelectionOverlayView: UIView {
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
@ -5,12 +5,22 @@ import Foundation
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
// MARK: - 可选中文本视图
|
||||
|
||||
/// 自定义 UITextView,替换系统默认的 UIMenuItem 为自定义操作(拷贝、高亮、批注)
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
/// 选择菜单操作回调
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
@ -37,6 +47,10 @@ final class RDEPUBSelectableTextView: UITextView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DTCoreText 直接绘制视图
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染
|
||||
#if canImport(DTCoreText)
|
||||
final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
@ -73,6 +87,14 @@ final class RDEPUBDirectCoreTextPageView: UIView {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Web 内容视图代理
|
||||
|
||||
/// Web 内容视图的代理协议
|
||||
/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
/// WebView 中阅读位置更新时调用
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// WebView 中文本选择变化时调用
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 用户从选择菜单中触发操作
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 用户点击内部链接(章节跳转)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 用户点击外部链接
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
/// WebView 中 JavaScript 执行出错
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图
|
||||
|
||||
/// EPUB 固定布局和 Web 渲染路径的内容视图
|
||||
/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制
|
||||
/// 适用于固定布局 EPUB 和不支持 Native Text 的场景
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
|
||||
@ -1,10 +1,41 @@
|
||||
//
|
||||
// RDURLReaderController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
|
||||
// 该文件是 ReadViewSDK 的最简使用入口:
|
||||
// - .epub 文件:直接创建 RDEPUBReaderController
|
||||
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
|
||||
// - 分页失败时回退到纯文本 UITextView 展示
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// URL 阅读器入口控制器
|
||||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```swift
|
||||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
/// 内嵌的阅读器控制器
|
||||
private var embeddedController: UIViewController?
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - bookURL: 书籍文件的本地 URL
|
||||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||||
public init(
|
||||
bookURL: URL,
|
||||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||||
@ -19,6 +50,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@ -26,11 +58,18 @@ public final class RDURLReaderController: UIViewController {
|
||||
embedReaderController()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
/// 跳转到指定页码(Demo 用)
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
@ -40,6 +79,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
return moved
|
||||
}
|
||||
|
||||
/// 执行翻页模式切换序列(Demo 用)
|
||||
/// 按照指定顺序和延迟依次切换翻页模式
|
||||
/// - Parameters:
|
||||
/// - displayTypes: 翻页模式数组
|
||||
/// - initialPageNumber: 可选的初始跳转页码
|
||||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||||
public func runDemoDisplaySequence(
|
||||
_ displayTypes: [RDReaderView.DisplayType],
|
||||
initialPageNumber: Int? = nil,
|
||||
@ -60,6 +105,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 嵌入阅读器控制器到当前视图层级
|
||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
@ -104,10 +151,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
controller.didMove(toParent: self)
|
||||
}
|
||||
|
||||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||||
private var readerController: RDEPUBReaderController? {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
let location = readerController.currentLocation
|
||||
@ -117,6 +166,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸
|
||||
/// 基于屏幕尺寸和配置的内边距
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = epubConfiguration.reflowableContentInsets
|
||||
@ -126,6 +177,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||||
@ -137,6 +189,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 解码文本文件内容
|
||||
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
@ -151,7 +205,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
private extension RDReaderView.DisplayType {
|
||||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||||
var demoArgumentValue: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
@ -1,304 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
struct EPUBTOCDisplayItem {
|
||||
var title: String
|
||||
var href: String
|
||||
var depth: Int
|
||||
var pageNumber: Int?
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextPage {
|
||||
var absolutePageIndex: Int
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var chapterTitle: String
|
||||
var pageIndexInChapter: Int
|
||||
var totalPagesInChapter: Int
|
||||
var content: NSAttributedString
|
||||
var contentRange: NSRange
|
||||
var pageStartOffset: Int
|
||||
var pageEndOffset: Int
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextChapter {
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var title: String
|
||||
var attributedContent: NSAttributedString
|
||||
var fragmentOffsets: [String: Int]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
|
||||
func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class LegacyRDEPUBTextBookBuilder {
|
||||
static func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter] = []
|
||||
var flatPages: [LegacyRDEPUBTextPage] = []
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let markedHTML = injectFragmentMarkers(into: normalizedHTML)
|
||||
guard let attributedContent = attributedHTML(
|
||||
html: markedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let mutableContent = NSMutableAttributedString(attributedString: attributedContent)
|
||||
let fragmentOffsets = extractFragmentOffsets(from: mutableContent)
|
||||
normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing)
|
||||
|
||||
let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0
|
||||
? [NSRange(location: 0, length: mutableContent.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
LegacyRDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: mutableContent.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = LegacyRDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: mutableContent.copy() as! NSAttributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
chapters.append(chapter)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private static func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
private static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
private static func attributedHTML(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> NSAttributedString? {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue,
|
||||
NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any
|
||||
]
|
||||
|
||||
if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed
|
||||
}
|
||||
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: lineSpacing)
|
||||
]
|
||||
return NSAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var offsetAdjustment = 0
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
offsetAdjustment -= match.range.length
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: lineSpacing)
|
||||
paragraph.lineSpacing = lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
return baseFont
|
||||
}
|
||||
|
||||
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
//
|
||||
// RDReaderBottomToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
public class RDReaderBottomToolView: RDReaderToolView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chapterList, highlight, settings, darkAndLight
|
||||
}
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
|
||||
lazy var chapterListButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_chapterlist", fallbackSystemName: "list.bullet"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(chapterListAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var settingsButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_font", fallbackSystemName: "textformat"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var highlightButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "highlighter"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
|
||||
lazy var lightModeButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_night", fallbackSystemName: "moon.fill"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(lightAndDarkModeAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(chapterListButton)
|
||||
containerView.addArrangedSubview(highlightButton)
|
||||
containerView.addArrangedSubview(settingsButton)
|
||||
containerView.addArrangedSubview(lightModeButton)
|
||||
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.top.equalTo(0)
|
||||
make.right.equalTo(-16)
|
||||
make.bottom.equalTo(-RDReaderCommon.safeAreaInsets.bottom)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc private func chapterListAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chapterList)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func settingsAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func highlightAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.highlight)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func lightAndDarkModeAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.darkAndLight)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
//
|
||||
// RDReaderCommon.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
struct RDReaderCommon {
|
||||
static var safeAreaInsets: UIEdgeInsets = {
|
||||
guard #available(iOS 11.0, *) else {
|
||||
return .zero
|
||||
}
|
||||
return UIApplication.shared.windows[0].safeAreaInsets
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum SpaceType: Int, RDReaderObserverType {
|
||||
case min = 0
|
||||
case meduim
|
||||
case max
|
||||
var mulitiple: CGFloat {
|
||||
switch self {
|
||||
case .min: return 1.6
|
||||
case .meduim: return 2.0
|
||||
case .max: return 3.0
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
switch self {
|
||||
case .min:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_3", fallbackSystemName: "text.justify.leading")
|
||||
case .meduim:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_2", fallbackSystemName: "text.justify")
|
||||
case .max:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_1", fallbackSystemName: "text.justify.right")
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: CGFloat {
|
||||
return mulitiple
|
||||
}
|
||||
func transform(value: Any?) -> RDReaderCommon.SpaceType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.SpaceType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum PageCurlType: Int, RDReaderObserverType {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
case horizontalCoverScroll
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return "仿真"
|
||||
case .horizontalScroll:
|
||||
return "左右滑动"
|
||||
case .verticalScroll:
|
||||
return "上下滚动"
|
||||
case .horizontalCoverScroll:
|
||||
return "左右覆盖"
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return RDReaderView.DisplayType.pageCurl
|
||||
case .horizontalScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
case .verticalScroll:
|
||||
return RDReaderView.DisplayType.verticalScroll
|
||||
case .horizontalCoverScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.PageCurlType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.PageCurlType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
|
||||
enum Colors: Int, RDReaderObserverType{
|
||||
|
||||
case white, dark, pink, yellow, green, blue
|
||||
var value: RDReaderTheme {
|
||||
switch self {
|
||||
case .white:
|
||||
return whiteTheme
|
||||
case .dark:
|
||||
return darkTheme
|
||||
case .pink:
|
||||
return pinkTheme
|
||||
case .yellow:
|
||||
return yellowTheme
|
||||
case .green:
|
||||
return greenTheme
|
||||
case .blue:
|
||||
return blueTheme
|
||||
}
|
||||
}
|
||||
var cachesValue: Int {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .white:
|
||||
return UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1)
|
||||
case .yellow:
|
||||
return UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
case .green:
|
||||
return UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
case .pink:
|
||||
return UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
case .blue:
|
||||
return UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
case .dark:
|
||||
return UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.Colors {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.Colors(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
static let whiteTheme = WhiteTheme()
|
||||
static let darkTheme = DarkTheme()
|
||||
static let pinkTheme = PinkTheme()
|
||||
static let yellowTheme = YellowTheme()
|
||||
static let greenTheme = GreenTheme()
|
||||
static let blueTheme = BlueTheme()
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension NSAttributedString {
|
||||
func pageRanges(size: CGSize) -> [NSRange] {
|
||||
var ranges = [NSRange]()
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(self)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
|
||||
var range = CFRangeMake(0, 0)
|
||||
var loc = 0
|
||||
while range.location + range.length < length {
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(loc, 0), path, nil)
|
||||
range = CTFrameGetVisibleStringRange(frame)
|
||||
ranges.append(NSMakeRange(loc, range.length))
|
||||
loc += range.length
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension String {
|
||||
static func encodeTextFile(url: URL?) -> String {
|
||||
var content: String? = nil
|
||||
guard let url = url else {
|
||||
return content ?? ""
|
||||
}
|
||||
content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000632) as String
|
||||
}
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000631) as String
|
||||
}
|
||||
return content ?? ""
|
||||
}
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
//
|
||||
// RDReaderContentView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderContentView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.text = """
|
||||
孩子为了不进职校,压力之大,我甚至听说,有初三学生晚上八点睡觉,凌晨两点起床读书做作业,忙到天亮,吃好早饭赶往学校。
|
||||
|
||||
平心静气地讲,一个孩子如果不是读书的料,完全可以去职校学一门手艺。但家长却有三重焦虑:
|
||||
|
||||
第一,一个普通高校的学生可以轻松去读职校的课程,但职校学生要去读普通高校的课程,难上加难。同一年级的各类学校和专业,在学习上是有难易梯度的。家长多不会同意让孩子一开始就选择容易的学校和专业,否则在未来竞争中,将处于不利地位。
|
||||
|
||||
第二,初中毕业生社会经验匮乏,实际上根本没有能力做人生规划。选择职校学一门技术,同时也就意味着,将来从事其他工作的门槛是很高的。如果没有继续学习的能力,改行的成本之高,难以想象。
|
||||
|
||||
以我的个人经验来讲,初中阶段,一度想去学习屠宰的手艺,毕业时也有上建筑类职高的机会,但都没去,在普通高中混到高三才决定考大学,如果去上职高,很可能就是个小包工头。我不知道这是不是我的真实意愿,但我觉得当下的工作更符合秉性。
|
||||
|
||||
第三,中国的一些职校,风评并不十分好,家长并不十分放心把十五六岁的孩子送入这些学校,他们不担心孩子学艺不成,而是担心孩子“学坏了”。
|
||||
|
||||
并且,随着科技加速进步,很多好端端的传统职业,忽然消失了。以汽修为例,现在的汽修专业毕业生,谁能保证他的精湛技术在10年之后不会归零?一些新职业出现没几年又消失了,谁能保证中高职教育能够跟上科技潮流?
|
||||
"""
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class RDReaderEPUBTextContentView: UIView {
|
||||
private(set) lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
private(set) lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: LegacyRDEPUBTextPage, pageNumber: Int, totalPages: Int, theme: RDReaderTheme) {
|
||||
backgroundColor = theme.contentBackgroudColor
|
||||
pageNumLabel.textColor = theme.contentTextColor
|
||||
pageNumLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(.foregroundColor, value: theme.contentTextColor ?? UIColor.black, range: fullRange)
|
||||
textLabel.attributedText = displayContent
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
extension RDReaderController {
|
||||
func currentEPUBTextPageSize() -> CGSize {
|
||||
let pageWidth: CGFloat
|
||||
if readerView.pagesPerScreen > 1 {
|
||||
pageWidth = view.frame.width / CGFloat(readerView.pagesPerScreen) - 16 * 2
|
||||
} else {
|
||||
pageWidth = view.frame.width - 16 * 2
|
||||
}
|
||||
return CGSize(width: max(pageWidth, 1), height: max(view.frame.height - 40 * 2, 1))
|
||||
}
|
||||
|
||||
func loadEPUBTextBook(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
let pageSize = currentEPUBTextPageSize()
|
||||
let font = UIFont.systemFont(ofSize: RDReaderManager.shared.fontValue.currentType)
|
||||
let lineSpacing = RDReaderManager.shared.lineSpaceType.currentType.mulitiple * 15
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
let textBook = LegacyRDEPUBTextBookBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyEPUBTextBook(textBook, publication: publication, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEPUBTextBook(
|
||||
_ textBook: LegacyRDEPUBTextBook,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
epubTextBook = textBook
|
||||
epubTOCItems = flattenedTOCItems(from: publication.tableOfContents, textBook: textBook)
|
||||
print("[EPUB] Text book ready chapters=\(textBook.chapters.count) pages=\(textBook.pages.count) toc=\(epubTOCItems.count)")
|
||||
let resolvedRestorePage = restoreLocation.flatMap {
|
||||
textBook.pageNumber(
|
||||
for: $0,
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
storeEPUBPaginationValidation(
|
||||
mode: "textReflowable",
|
||||
stage: "final",
|
||||
pageCount: textBook.pages.count,
|
||||
chapterCount: textBook.chapters.count,
|
||||
tocCount: epubTOCItems.count,
|
||||
restoreLocation: restoreLocation,
|
||||
resolvedRestorePage: resolvedRestorePage
|
||||
)
|
||||
isReaderContentReady = true
|
||||
hideLoading()
|
||||
readerView.reloadData()
|
||||
restoreEPUBLocation(restoreLocation)
|
||||
if restoreLocation == nil {
|
||||
epubSession?.transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
func flattenedTOCItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0,
|
||||
textBook: LegacyRDEPUBTextBook? = nil
|
||||
) -> [EPUBTOCDisplayItem] {
|
||||
var result: [EPUBTOCDisplayItem] = []
|
||||
for item in items {
|
||||
let pageNumber = textBook.flatMap { book in
|
||||
book.pageNumber(
|
||||
for: RDEPUBLocation(bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0),
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
result.append(EPUBTOCDisplayItem(title: item.title, href: item.href, depth: depth, pageNumber: pageNumber))
|
||||
result.append(contentsOf: flattenedTOCItems(from: item.children, depth: depth + 1, textBook: textBook))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func publicationFallbackResolver() -> RDEPUBResourceResolver {
|
||||
epubPublication?.resourceResolver ?? RDEPUBResourceResolver(parser: epubParser ?? RDEPUBParser())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,47 +0,0 @@
|
||||
//
|
||||
// RDReaderCoverView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderCoverView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let textLabel = UILabel()
|
||||
textLabel.numberOfLines = 0
|
||||
return textLabel
|
||||
}()
|
||||
|
||||
lazy var imageV: UIImageView = {
|
||||
let imageV = UIImageView()
|
||||
imageV.image = UIImage(named: "cover")
|
||||
return imageV
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(imageV)
|
||||
addSubview(textLabel)
|
||||
|
||||
imageV.snp.makeConstraints { make in
|
||||
make.top.equalTo(60)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 88 * 1.5, height: 120 * 1.5))
|
||||
}
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.top.equalTo(imageV.snp.bottom).offset(20)
|
||||
make.bottom.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
protocol RDReaderEPUBContentViewDelegate: AnyObject {
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
final class RDReaderEPUBContentView: UIView {
|
||||
weak var delegate: RDReaderEPUBContentViewDelegate?
|
||||
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
addSubview(epubWebView)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
epubWebView.delegate = self
|
||||
epubWebView.clipsToBounds = true
|
||||
epubWebView.layer.masksToBounds = true
|
||||
|
||||
epubWebView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.right.equalToSuperview().offset(-30)
|
||||
make.bottom.equalToSuperview().offset(-20)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
}
|
||||
|
||||
func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String?,
|
||||
themeTextColor: String?,
|
||||
targetLocation: RDEPUBLocation?,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
epubWebView.loadPage(
|
||||
parser: parser,
|
||||
spineIndex: spineIndex,
|
||||
pageIndex: pageIndex,
|
||||
totalPagesInChapter: totalPagesInChapter,
|
||||
viewportSize: viewportSize,
|
||||
padding: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor,
|
||||
targetLocation: targetLocation,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColor: UIColor?
|
||||
) {
|
||||
epubWebView.loadFixedSpread(
|
||||
parser: parser,
|
||||
spread: spread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: contentInset,
|
||||
backgroundColor: backgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
epubWebView.reset()
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderEPUBContentView: RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
delegate?.epubContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubContentView(self, didActivateExternalLink: url)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||
delegate?.epubContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {
|
||||
}
|
||||
}
|
||||
@ -1,499 +0,0 @@
|
||||
//
|
||||
// RDReaderEditView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 亮度
|
||||
class RDReaderBrightSlider: UISlider, SSEventTrigger {
|
||||
enum Event {
|
||||
case valueDidChange(value: CGFloat)
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
override func setValue(_ value: Float, animated: Bool) {
|
||||
super.setValue(value, animated: animated)
|
||||
UIScreen.main.brightness = CGFloat(value)
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.valueDidChange(value: CGFloat(value)))
|
||||
}
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
let color = UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1)
|
||||
thumbTintColor = color
|
||||
let image = UIImage.rdToolbarImage(named: "read_edit_slide", fallbackSystemName: "circle.fill")
|
||||
setThumbImage(image, for: .normal)
|
||||
setThumbImage(image, for: .highlighted)
|
||||
minimumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_min", fallbackSystemName: "sun.min.fill")
|
||||
maximumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_max", fallbackSystemName: "sun.max.fill")
|
||||
}
|
||||
|
||||
override func trackRect(forBounds bounds: CGRect) -> CGRect {
|
||||
self.layer.cornerRadius = 2.5 / 2
|
||||
return CGRect(x: 30, y: (bounds.height - 2.5)/2, width: bounds.width - 30 * 2, height: 2.5)
|
||||
}
|
||||
|
||||
override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
|
||||
// print(value)
|
||||
return CGRect(x: 30 + (bounds.width - 30 * 2 - 16) * CGFloat(value), y: (bounds.height - 16)/2, width: 16, height: 16)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 字号
|
||||
class RDReaderFontView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case fontSize(size: CGFloat)
|
||||
}
|
||||
var maxFontSize: CGFloat = 30
|
||||
var minFontSize: CGFloat = 15
|
||||
var currentFontSize: CGFloat = 15 {
|
||||
didSet {
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
if currentFontSize != oldValue {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.fontSize(size: currentFontSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lazy var reduceButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_reduce", fallbackSystemName: "textformat.size.smaller"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var increaseButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_increase", fallbackSystemName: "textformat.size.larger"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var fontLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 17)
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 0
|
||||
return stackView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(reduceButton)
|
||||
containerView.addArrangedSubview(fontLabel)
|
||||
containerView.addArrangedSubview(increaseButton)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
reduceButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(increaseButton.snp.width).priority(.high)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
increaseButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
fontLabel.snp.makeConstraints { make in
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
|
||||
reduceButton.addTarget(self, action: #selector(reduceAction(button:)), for: .touchUpInside)
|
||||
increaseButton.addTarget(self, action: #selector(increaseAction(button:)), for: .touchUpInside)
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
}
|
||||
|
||||
@objc func reduceAction(button: UIButton) {
|
||||
if currentFontSize == minFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize -= 1
|
||||
}
|
||||
|
||||
@objc func increaseAction(button: UIButton) {
|
||||
if currentFontSize == maxFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize += 1
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 行距
|
||||
class RDReaderLineSpaceView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.SpaceType)
|
||||
}
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.SpaceType] = [.min, .meduim, .max]
|
||||
var currentType: RDReaderCommon.SpaceType = .min {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setImage(type.image?.withRenderingMode(.alwaysOriginal), for: .normal)
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .min {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页方式
|
||||
class RDReaderPageCurlTypeView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.PageCurlType)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.PageCurlType = .pageCurl {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.PageCurlType] = [.pageCurl, .horizontalScroll, .verticalScroll, .horizontalCoverScroll]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.setTitleColor(UIColor.black, for: .normal)
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setTitle(type.title, for: .normal)
|
||||
let size = button.titleLabel!.sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), height: 37))
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
make.width.equalTo(size.width + 30)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .pageCurl {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题颜色
|
||||
class RDReaderThemeColorsView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chose(color: RDReaderCommon.Colors)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 30
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.Colors = .white {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.Colors] = [.white, .yellow, .green, .pink, .blue, .dark]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.layer.borderColor = UIColor.black.cgColor
|
||||
button.backgroundColor = type.color
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.width.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .white {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderWidth = 1
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(color: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderWidth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let space = (frame.width - CGFloat(allTypes.count) * 37) / CGFloat(allTypes.count - 1)
|
||||
containerView.spacing = space
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 编辑弹窗
|
||||
class RDReaderEditView: UIView {
|
||||
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.lightGray
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var brightSlider: RDReaderBrightSlider = {
|
||||
let slider = RDReaderBrightSlider()
|
||||
return slider
|
||||
}()
|
||||
|
||||
lazy var fontView: RDReaderFontView = {
|
||||
let view = RDReaderFontView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var lineSpaceView: RDReaderLineSpaceView = {
|
||||
let view = RDReaderLineSpaceView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var pageCurlTypeView: RDReaderPageCurlTypeView = {
|
||||
let view = RDReaderPageCurlTypeView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var colorView: RDReaderThemeColorsView = {
|
||||
let view = RDReaderThemeColorsView()
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
backgroundColor = .white
|
||||
addSubview(lineView)
|
||||
addSubview(brightSlider)
|
||||
addSubview(fontView)
|
||||
addSubview(lineSpaceView)
|
||||
addSubview(pageCurlTypeView)
|
||||
addSubview(colorView)
|
||||
|
||||
lineView.snp.makeConstraints { make in
|
||||
make.left.right.top.equalTo(0)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
brightSlider.snp.makeConstraints { make in
|
||||
make.top.equalTo(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(20)
|
||||
}
|
||||
|
||||
fontView.snp.makeConstraints { make in
|
||||
make.top.equalTo(brightSlider.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
lineSpaceView.snp.makeConstraints { make in
|
||||
make.top.equalTo(fontView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
pageCurlTypeView.snp.makeConstraints { make in
|
||||
make.top.equalTo(lineSpaceView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
colorView.snp.makeConstraints { make in
|
||||
make.top.equalTo(pageCurlTypeView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
//
|
||||
// RDReaderEndView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderEndView: UIView {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let textLabel = UILabel()
|
||||
textLabel.text = "结束页"
|
||||
textLabel.backgroundColor = .red
|
||||
textLabel.textAlignment = .center
|
||||
addSubview(textLabel)
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 100, height: 100))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,325 +0,0 @@
|
||||
//
|
||||
// RDReaderManager.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct Book {
|
||||
var desc: String?
|
||||
var chapters: [Chapter]
|
||||
}
|
||||
|
||||
struct Chapter {
|
||||
/// 第几章
|
||||
var sort: Int
|
||||
/// 章节名称
|
||||
var title: String?
|
||||
/// 划分了多少页
|
||||
var pageCount: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 分页
|
||||
var pages: [ChapterPage]
|
||||
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
struct ChapterPage {
|
||||
/// 第几章
|
||||
var chapterSort: Int
|
||||
/// 第几页
|
||||
var pageNum: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 内容
|
||||
var attrContent: NSAttributedString
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
public struct DemoBookItem {
|
||||
public var title: String
|
||||
public var source: BookSource
|
||||
|
||||
public init(title: String, source: BookSource) {
|
||||
self.title = title
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
static let themeTypeKey = "themeType.key"
|
||||
static let brightValueKey = "brightValue.key"
|
||||
static let fontValueKey = "fontValue.key"
|
||||
static let lineSpaceKey = "lineSpace.key"
|
||||
static let pageCurlTypeKey = "pageCurlType.key"
|
||||
static let readProgressKey = "readProgress.key"
|
||||
}
|
||||
|
||||
typealias BrightValue = CGFloat
|
||||
extension BrightValue: RDReaderObserverType {
|
||||
var cachesValue: CGFloat {
|
||||
return self
|
||||
}
|
||||
var value: CGFloat {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> BrightValue {
|
||||
return (value as? BrightValue) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: RDReaderObserverType {
|
||||
var cachesValue: Int {
|
||||
return self
|
||||
}
|
||||
var value: Int {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> Int {
|
||||
return (value as? Int) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension String: RDReaderObserverType {
|
||||
var cachesValue: String {
|
||||
return self
|
||||
}
|
||||
|
||||
var value: String {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> String {
|
||||
return (value as? String) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
public class RDReaderManager: NSObject {
|
||||
public static let shared = RDReaderManager()
|
||||
let themeType = RDReaderObserver(key: RDReaderManager.themeTypeKey, defaultType: RDReaderCommon.Colors.white)
|
||||
let brightValue = RDReaderObserver(key: RDReaderManager.brightValueKey, defaultType: UIScreen.main.brightness)
|
||||
let fontValue = RDReaderObserver(key: RDReaderManager.fontValueKey, defaultType: CGFloat(15.0))
|
||||
let lineSpaceType = RDReaderObserver(key: RDReaderManager.lineSpaceKey, defaultType: RDReaderCommon.SpaceType.min)
|
||||
let pageCurlTypeType = RDReaderObserver(key: RDReaderManager.pageCurlTypeKey, defaultType: RDReaderCommon.PageCurlType.pageCurl)
|
||||
let readProgress = RDReaderObserver(key: RDReaderManager.readProgressKey, defaultType: 0)
|
||||
let epubReadLocation = RDReaderObserver(key: "epubReadLocation.key", defaultType: "")
|
||||
let epubSelection = RDReaderObserver(key: "epubSelection.key", defaultType: "")
|
||||
let epubHighlights = RDReaderObserver(key: "epubHighlights.key", defaultType: "")
|
||||
|
||||
}
|
||||
|
||||
protocol RDReaderObserverType {
|
||||
associatedtype Value
|
||||
associatedtype CacheValue
|
||||
associatedtype CurrentType
|
||||
var cachesValue: CacheValue { get }
|
||||
var value: Value { get }
|
||||
func transform(value: Any?) -> CurrentType
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RDReaderObserver<CurrentType> where CurrentType: RDReaderObserverType {
|
||||
private var _currentType: CurrentType? = nil
|
||||
private var key: String
|
||||
private var defaultType: CurrentType
|
||||
typealias Observer = ((CurrentType.Value) -> Void)
|
||||
private var observers = [WeakObserver<Observer>]()
|
||||
var currentType: CurrentType {
|
||||
get {
|
||||
if _currentType != nil {
|
||||
return _currentType!
|
||||
} else {
|
||||
_currentType = (self.defaultType.transform(value: UserDefaults.standard.object(forKey: key)) as? CurrentType) ?? self.defaultType
|
||||
return _currentType!
|
||||
}
|
||||
}
|
||||
set {
|
||||
_currentType = newValue
|
||||
UserDefaults.standard.set(newValue.cachesValue, forKey: key)
|
||||
UserDefaults.standard.synchronize()
|
||||
observers.forEach { observer in
|
||||
if let observer = observer.observer?.observer {
|
||||
observer(_currentType!.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init(key: String, defaultType: CurrentType) {
|
||||
self.key = key
|
||||
self.defaultType = defaultType
|
||||
}
|
||||
|
||||
func observeChange(onNext: Observer?) {
|
||||
if let onNext = onNext {
|
||||
onNext(currentType.value)
|
||||
observers.append(WeakObserver(observer: onNext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class WeakObserver<T> {
|
||||
class _Observer<T>: NSObject {
|
||||
var observer: T
|
||||
init(observer: T) {
|
||||
self.observer = observer
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
weak var observer: _Observer<T>?
|
||||
private var _strongObserver: _Observer<T>?
|
||||
init(observer: T) {
|
||||
let t = _Observer(observer: observer)
|
||||
_strongObserver = t
|
||||
self.observer = _strongObserver
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
func epubReaderConfiguration() -> RDEPUBReaderConfiguration {
|
||||
let theme = themeType.currentType.value
|
||||
return RDEPUBReaderConfiguration(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
displayType: pageCurlTypeType.currentType.value,
|
||||
landscapeDualPageEnabled: true,
|
||||
reflowableContentInsets: UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: .zero,
|
||||
theme: RDEPUBReaderTheme(
|
||||
contentBackgroundColor: theme.contentBackgroudColor ?? .white,
|
||||
contentTextColor: theme.contentTextColor ?? .black,
|
||||
toolBackgroundColor: theme.toolBackgroudColor ?? .white,
|
||||
toolControlTextColor: theme.toolControlTextColor ?? .black,
|
||||
toolControlBorderUnselectColor: theme.toolControlBorderUnSelectColor ?? UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: theme.toolLineColor ?? UIColor.lightGray.withAlphaComponent(0.5)
|
||||
),
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic,
|
||||
textRenderingEngine: .dtCoreText
|
||||
)
|
||||
}
|
||||
|
||||
func epubPreferences(
|
||||
theme: RDReaderTheme? = nil,
|
||||
reflowableInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets
|
||||
) -> RDEPUBPreferences {
|
||||
let resolvedTheme = theme ?? themeType.currentType.value
|
||||
let backgroundColorCSS = resolvedTheme.contentBackgroudColor?.ss_cssString
|
||||
return RDEPUBPreferences(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
reflowableContentInsets: reflowableInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
themeBackgroundColor: backgroundColorCSS,
|
||||
themeTextColor: resolvedTheme.contentTextColor?.ss_cssString,
|
||||
fixedBackgroundColor: backgroundColorCSS,
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic
|
||||
)
|
||||
}
|
||||
|
||||
func bundledEPUBURLs(in bundle: Bundle = .main) -> [URL] {
|
||||
(bundle.urls(forResourcesWithExtension: "epub", subdirectory: nil) ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
lhs.deletingPathExtension().lastPathComponent.localizedStandardCompare(rhs.deletingPathExtension().lastPathComponent) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
public func demoBookItems(in bundle: Bundle = .main) -> [DemoBookItem] {
|
||||
let textDemo = DemoBookItem(title: "宠她.txt", source: .textFile)
|
||||
let epubItems = bundledEPUBURLs(in: bundle).map { url in
|
||||
DemoBookItem(title: url.deletingPathExtension().lastPathComponent, source: .epubFile(url: url))
|
||||
}
|
||||
return [textDemo] + epubItems
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func startReading(book item: DemoBookItem, from navigationController: UINavigationController?, animated: Bool = true) -> UIViewController? {
|
||||
let readerController: UIViewController
|
||||
switch item.source {
|
||||
case .textFile:
|
||||
guard let textURL = Bundle.main.url(forResource: "宠她", withExtension: "txt") else {
|
||||
return nil
|
||||
}
|
||||
readerController = RDURLReaderController(bookURL: textURL)
|
||||
case .epubFile(let url):
|
||||
readerController = RDURLReaderController(
|
||||
bookURL: url,
|
||||
epubConfiguration: epubReaderConfiguration()
|
||||
)
|
||||
}
|
||||
readerController.title = item.title
|
||||
navigationController?.pushViewController(readerController, animated: animated)
|
||||
return readerController
|
||||
}
|
||||
|
||||
func encodeTextFile(font: UIFont, lineSapce: CGFloat, size: CGSize) -> Book {
|
||||
let decodeString = String.encodeTextFile(url: URL(fileURLWithPath: Bundle.main.path(forResource: "宠她", ofType: ".txt")!))
|
||||
let parten = "第[0-9一二三四五六七八九十百千]*[章回].*"
|
||||
var chapters = [Chapter]()
|
||||
var desc: String?
|
||||
if let expression = try? NSRegularExpression(pattern: parten, options: .caseInsensitive) {
|
||||
let results = expression.matches(in: decodeString, options: .reportCompletion, range: NSMakeRange(0, decodeString.count))
|
||||
var startCount = -1
|
||||
var lastTitle: String?
|
||||
for result in results {
|
||||
let index = results.firstIndex(of: result)
|
||||
let range = result.range
|
||||
|
||||
if startCount != -1 && index! > 0 {
|
||||
let contentRange = NSMakeRange(startCount, range.location - startCount)
|
||||
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index!,title: lastTitle, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
if index == results.count - 1 {
|
||||
let contentRange = NSMakeRange(range.location, decodeString.count - range.location)
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let title = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index! + 1, title: title, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
}
|
||||
}
|
||||
startCount = range.location
|
||||
lastTitle = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
if index == 0 && range.location > 0 {
|
||||
desc = decodeString.substring(with: Range(NSMakeRange(0, range.location), in: decodeString)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Book(desc: desc, chapters: chapters)
|
||||
}
|
||||
|
||||
func chapterPages(content: String, font: UIFont, lineSapce: CGFloat, size: CGSize, chapterSort: Int) -> [ChapterPage] {
|
||||
let attrString = NSMutableAttributedString(string: content)
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSapce
|
||||
attrString.addAttributes([NSAttributedString.Key.font : font, NSAttributedString.Key.paragraphStyle: style], range: NSMakeRange(0, content.count))
|
||||
let pageRanges = attrString.pageRanges(size: size)
|
||||
let pages = pageRanges.map({
|
||||
pageRange -> ChapterPage in
|
||||
let pageContent = content.substring(with: Range(pageRange, in: content)!)
|
||||
let pageAttrContent = attrString.attributedSubstring(from: pageRange)
|
||||
return ChapterPage(chapterSort: chapterSort, pageNum: pageRanges.firstIndex(of: pageRange)!, content: pageContent, attrContent: pageAttrContent, range: pageRange)
|
||||
})
|
||||
|
||||
return pages
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
//
|
||||
// RDReaderTheme.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
|
||||
public protocol RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? { get }
|
||||
var contentTextColor: UIColor? { get }
|
||||
var toolBackgroudColor: UIColor? { get }
|
||||
var toolControlTextColor: UIColor? { get }
|
||||
var toolControlBorderUnSelectColor: UIColor? { get }
|
||||
var toolLineColor: UIColor? { get }
|
||||
}
|
||||
|
||||
struct WhiteTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.white
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor.white
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct DarkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.black
|
||||
var contentTextColor: UIColor? = UIColor.white
|
||||
var toolBackgroudColor: UIColor? = UIColor.black
|
||||
var toolControlTextColor: UIColor? = UIColor.white
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct YellowTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct GreenTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct PinkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct BlueTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
//
|
||||
// RDReaderToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class RDReaderToolView: UIView {
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
var lineHeight: CGFloat = 0.5
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
|
||||
func makeUI() {
|
||||
addSubview(lineView)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: 0, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class TintColorButton: UIButton {
|
||||
public override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
if let image = self.currentImage {
|
||||
let image = image.tintColorImage(color: tintColor)
|
||||
setImage(image, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension UIImage {
|
||||
static func rdToolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
|
||||
func tintColorImage(color: UIColor) -> UIImage? {
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, scale)
|
||||
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
color.set()
|
||||
UIRectFill(rect)
|
||||
draw(at: .zero, blendMode: .destinationIn, alpha: 1)
|
||||
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return newImage
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
//
|
||||
// RDReaderTopToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
|
||||
|
||||
public class RDReaderTopToolView: RDReaderToolView,SSEventTrigger {
|
||||
|
||||
|
||||
enum Event {
|
||||
case back
|
||||
}
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: frame.height - lineHeight, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
lazy var backButton: TintColorButton = {
|
||||
let backButton = TintColorButton(type: .system)
|
||||
backButton.setImage(toolbarImage(named: "arrow_left", fallbackSystemName: "chevron.left"), for: .normal)
|
||||
return backButton
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(backButton)
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.left.equalTo(0)
|
||||
make.bottom.equalTo(0)
|
||||
make.height.equalTo(44)
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.back)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
//
|
||||
// SSChapterListController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
class SSChapterListController: UITableViewController {
|
||||
var selectedChapter: ((Int) -> Void)?
|
||||
var chapters: [Chapter]
|
||||
var currentChapterNum: Int
|
||||
init(chapters:[Chapter], currentChapterNum: Int) {
|
||||
self.chapters = chapters
|
||||
self.currentChapterNum = currentChapterNum
|
||||
super.init(style: .plain)
|
||||
|
||||
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 0))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.chapters.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell")
|
||||
cell?.textLabel?.text = self.chapters[indexPath.row].title
|
||||
cell?.textLabel?.textColor = indexPath.row == currentChapterNum ? UIColor.red : UIColor.black
|
||||
return cell!
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if let selectedChapter = selectedChapter {
|
||||
selectedChapter(indexPath.row)
|
||||
}
|
||||
}
|
||||
|
||||
func selectedChapter(onTrigger: @escaping (Int) -> Void) {
|
||||
self.selectedChapter = onTrigger
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
//
|
||||
// EventTrigger.swift
|
||||
//
|
||||
//
|
||||
// Created by yangsq on 2020/11/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SSEventTrigger {
|
||||
associatedtype Event
|
||||
typealias TriggerEvent = (Event) -> Void
|
||||
var triggerEvent: TriggerEvent? { get set }
|
||||
func trigger(event: TriggerEvent?)
|
||||
}
|
||||
|
||||
private var triggerEventKey: UInt8 = 0
|
||||
extension SSEventTrigger {
|
||||
private var _triggerEvent:TriggerEvent? {
|
||||
get {return objc_getAssociatedObject(self, &triggerEventKey) as? Self.TriggerEvent}
|
||||
set {objc_setAssociatedObject(self, &triggerEventKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)}
|
||||
}
|
||||
|
||||
var triggerEvent: TriggerEvent? {
|
||||
get{_triggerEvent}
|
||||
set{
|
||||
if newValue != nil {
|
||||
_triggerEvent = newValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trigger(event: TriggerEvent?) {
|
||||
if event != nil {
|
||||
objc_setAssociatedObject(self, &triggerEventKey, event!, .OBJC_ASSOCIATION_COPY_NONATOMIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
//
|
||||
// RDReaderContentCell.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/7.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
private var _containerView: UIView? = nil
|
||||
var containerView: UIView? {
|
||||
set {
|
||||
guard newValue !== _containerView else { return }
|
||||
_containerView?.removeFromSuperview()
|
||||
_containerView = newValue
|
||||
if let containerView = _containerView {
|
||||
contentView.addSubview(containerView)
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
get {
|
||||
return _containerView
|
||||
}
|
||||
}
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
55
Sources/RDReaderView/ReaderView/RDReaderContentCell.swift
Normal file
55
Sources/RDReaderView/ReaderView/RDReaderContentCell.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// RDReaderContentCell.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/7.
|
||||
//
|
||||
// 文件职责:UICollectionViewCell 薄壳宿主,作为滚动模式下页面内容的容器。
|
||||
// 该 cell 本身不包含业务逻辑,仅持有 containerView 并通过自动布局将其填满。
|
||||
// 真正的内容视图由 RDReaderDataSource.pageContentView() 提供并赋值给 containerView。
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第三层的 cell 组件,被 UICollectionView 使用。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 内容 cell 薄壳宿主
|
||||
/// 作为 UICollectionView 滚动模式下的页面容器。
|
||||
/// 通过 ``containerView`` 属性持有实际内容视图,并在 layoutSubviews 中
|
||||
/// 将内容视图的 frame 设置为与 contentView 等大,实现自动填满。
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
/// 内部持有的内容视图引用
|
||||
private var _containerView: UIView? = nil
|
||||
|
||||
/// 内容视图属性
|
||||
/// 设置时会自动将旧视图移除、新视图添加到 contentView 中
|
||||
/// 读取时返回当前持有的内容视图
|
||||
var containerView: UIView? {
|
||||
set {
|
||||
guard newValue !== _containerView else { return }
|
||||
_containerView?.removeFromSuperview()
|
||||
_containerView = newValue
|
||||
if let containerView = _containerView {
|
||||
contentView.addSubview(containerView)
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
get {
|
||||
return _containerView
|
||||
}
|
||||
}
|
||||
|
||||
/// 布局子视图时调用
|
||||
/// 将 containerView 的 frame 设置为与 contentView 等大,实现自动填满
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@ -4,26 +4,53 @@
|
||||
//
|
||||
// Created by yangsq on 2021/8/6.
|
||||
//
|
||||
// 文件职责:自定义 UICollectionViewFlowLayout,支持阅读器的三种滚动布局模式。
|
||||
// 该文件实现了水平滚动(全屏宽 item + 分页)、垂直滚动(可变高度)和
|
||||
// 封面感知的双页布局(含封面页独占整屏逻辑)。
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第三层的布局组件,被 RDReaderView 持有和配置。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 流式布局数据源协议
|
||||
/// 提供垂直滚动模式下各页面的高度信息
|
||||
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
/// 返回垂直滚动模式下指定页的高度
|
||||
/// - Parameters:
|
||||
/// - flowLayout: 发起请求的布局对象
|
||||
/// - pageIndex: 页码索引
|
||||
/// - Returns: 该页的高度,nil 表示使用默认高度(collectionView.frame.height)
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
/// 流式布局代理协议
|
||||
/// 当当前可见页码变化时通知外部
|
||||
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
/// 页码变化回调
|
||||
/// - Parameters:
|
||||
/// - flowLayout: 发起回调的布局对象
|
||||
/// - pageIndex: 当前页码
|
||||
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
|
||||
/// 自定义流式布局,支持阅读器的三种滚动布局模式:
|
||||
/// - horizontalScroll:水平滚动,每屏1项(竖屏)或2项(横屏双页),支持分页
|
||||
/// - verticalScroll:垂直滚动,全宽项目,支持可变高度
|
||||
///
|
||||
/// 通过 ``displayType`` 属性切换布局模式。
|
||||
/// 通过 ``isLandscapeDualPage`` 和 ``coverPageIndex`` 控制横屏双页和封面页逻辑。
|
||||
public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
|
||||
|
||||
|
||||
/// 布局模式,切换时自动使布局失效并重新计算
|
||||
var displayType: RDReaderView.DisplayType = .horizontalScroll {
|
||||
didSet {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 是否启用横屏双页模式
|
||||
/// 启用后水平滚动模式下每屏显示两项(各占半屏宽度)
|
||||
var isLandscapeDualPage: Bool = false {
|
||||
didSet {
|
||||
if oldValue != isLandscapeDualPage {
|
||||
@ -31,7 +58,9 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 封面页索引
|
||||
/// 设置后封面页独占整屏,后续页面两两配对各占半屏
|
||||
var coverPageIndex: Int? = nil {
|
||||
didSet {
|
||||
if oldValue != coverPageIndex {
|
||||
@ -39,9 +68,12 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 上一次 prepare 时的 bounds 尺寸,用于检测尺寸变化
|
||||
private var lastPreparedBoundsSize: CGSize = .zero
|
||||
|
||||
|
||||
/// 每屏显示的页数
|
||||
/// 仅在水平滚动+横屏双页模式下返回2,其他情况返回1
|
||||
var pagesPerScreen: Int {
|
||||
guard isLandscapeDualPage else { return 1 }
|
||||
switch displayType {
|
||||
@ -54,14 +86,25 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 流式布局数据源,提供垂直滚动模式的页面高度
|
||||
weak var dataSource: RDReaderFlowLayoutDataSoure? = nil
|
||||
/// 流式布局代理,接收页码变化通知
|
||||
weak var delegate: RDReaderFlowLayoutDelegate? = nil
|
||||
|
||||
|
||||
/// 是否在双页模式下有封面页独占
|
||||
private var hasCoverPageInDualMode: Bool {
|
||||
return pagesPerScreen > 1 && coverPageIndex != nil
|
||||
}
|
||||
|
||||
|
||||
/// 封面感知的帧计算:根据索引计算 item 的 frame
|
||||
/// 封面页独占整屏,后续页面两两配对各占半屏
|
||||
/// - Parameters:
|
||||
/// - index: item 索引
|
||||
/// - screenWidth: 屏幕宽度
|
||||
/// - halfWidth: 半屏宽度
|
||||
/// - height: 屏幕高度
|
||||
/// - Returns: 该 item 的 frame
|
||||
private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
|
||||
guard let coverIndex = coverPageIndex else {
|
||||
let pairIdx = index / 2
|
||||
@ -81,8 +124,12 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
|
||||
return CGRect(x: x, y: 0, width: halfWidth, height: height)
|
||||
}
|
||||
|
||||
/// cover-aware screen start index calculation
|
||||
|
||||
/// 封面感知的屏幕起始索引计算:根据滚动偏移量计算当前屏幕的第一个 item 索引
|
||||
/// - Parameters:
|
||||
/// - offset: 当前滚动偏移量
|
||||
/// - screenWidth: 屏幕宽度
|
||||
/// - Returns: 当前屏幕第一个 item 的索引
|
||||
private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
|
||||
let pps = pagesPerScreen
|
||||
guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
|
||||
@ -99,22 +146,26 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
return coverIdx + 1 + pairIdx * 2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 当前可见的页码,值变化时通过 delegate 通知外部
|
||||
private var currentPage: Int = 0 {
|
||||
|
||||
|
||||
didSet {
|
||||
if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
|
||||
delegate.pageNum(flowLayout: self, pageIndex: currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameter displayType: 初始布局模式
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
self.displayType = displayType
|
||||
super.init()
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// 布局准备阶段,配置 item 尺寸、滚动方向和分页行为
|
||||
public override func prepare() {
|
||||
super.prepare()
|
||||
guard let collectionView = self.collectionView else {
|
||||
@ -123,9 +174,9 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
lastPreparedBoundsSize = collectionView.bounds.size
|
||||
|
||||
|
||||
if #available(iOS 11.0, *) {
|
||||
collectionView.contentInsetAdjustmentBehavior = .never
|
||||
} else {
|
||||
@ -149,8 +200,11 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// 判断 bounds 变化是否需要使布局失效
|
||||
/// 尺寸变化时总是返回 true,布局模式变化也返回 true
|
||||
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
if newBounds.size != lastPreparedBoundsSize {
|
||||
return true
|
||||
@ -164,7 +218,10 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 计算 collectionView 的总内容尺寸
|
||||
/// 垂直滚动模式:累加所有页面高度
|
||||
/// 封面双页模式:封面占一屏 + 后续页面按两两配对计算屏幕数
|
||||
public override var collectionViewContentSize: CGSize {
|
||||
var size = super.collectionViewContentSize
|
||||
guard let collectionView = collectionView else {
|
||||
@ -185,13 +242,16 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
|
||||
/// 计算指定矩形区域内的布局属性
|
||||
/// 垂直滚动模式:根据可变高度定位每个 item
|
||||
/// 水平滚动模式:根据当前偏移量计算当前页码,清除 cell 阴影
|
||||
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
var attributes = super.layoutAttributesForElements(in: rect)
|
||||
guard let collectionView = collectionView else {
|
||||
return attributes
|
||||
}
|
||||
|
||||
|
||||
if self.displayType == .verticalScroll {
|
||||
let rowCount = collectionView.numberOfItems(inSection: 0)
|
||||
var totalHeight: CGFloat = 0
|
||||
@ -218,18 +278,18 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if self.displayType == .horizontalScroll {
|
||||
let pps = pagesPerScreen
|
||||
|
||||
|
||||
if hasCoverPageInDualMode {
|
||||
let screenWidth = collectionView.frame.width
|
||||
let halfWidth = screenWidth / 2.0
|
||||
let rows = collectionView.numberOfItems(inSection: 0)
|
||||
|
||||
|
||||
self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
|
||||
|
||||
|
||||
var attrs = [UICollectionViewLayoutAttributes]()
|
||||
for index in 0..<rows {
|
||||
let frame = coverAwareFrame(for: index, screenWidth: screenWidth, halfWidth: halfWidth, height: collectionView.frame.height)
|
||||
@ -261,10 +321,14 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
|
||||
/// 根据页码计算对应的 contentOffset
|
||||
/// 用于跳转到指定页面时设置滚动位置
|
||||
/// - Parameter count: 目标页码
|
||||
/// - Returns: 对应的 contentOffset 坐标
|
||||
func currentContentOffset(count: Int) -> CGPoint {
|
||||
guard let collectionView = collectionView else {
|
||||
return .zero
|
||||
@ -303,7 +367,7 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
@ -4,30 +4,46 @@
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
// 文件职责:手势控制器(当前为占位组件)。
|
||||
// 该控制器持有顶部和底部工具栏视图,预留了手势管理的扩展点。
|
||||
// 目前仅作为数据容器,未实现具体的手势识别逻辑。
|
||||
// 实际的手势识别由 RDReaderView 中的 tapGestureRecognizer 处理。
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第三层的辅助组件。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
|
||||
|
||||
/// 手势控制器(占位组件)
|
||||
/// 持有顶部和底部工具栏视图,预留手势管理扩展。
|
||||
/// 当前功能由 RDReaderView 直接处理,该类未被实际使用。
|
||||
class RDReaderGestureController: UIViewController {
|
||||
/// 顶部工具栏视图
|
||||
var topToolView: UIView?
|
||||
/// 底部工具栏视图
|
||||
var bottomToolView: UIView?
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - topToolView: 顶部工具栏视图
|
||||
/// - bottomToolView: 底部工具栏视图
|
||||
init(topToolView: UIView?, bottomToolView: UIView?) {
|
||||
self.topToolView = topToolView
|
||||
self.bottomToolView = bottomToolView
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
// MARK: - Navigation
|
||||
@ -4,24 +4,47 @@
|
||||
//
|
||||
// Created by yangsq on 2021/8/9.
|
||||
//
|
||||
// 文件职责:UIPageViewController 的子控制器,持有单页的内容视图和页码。
|
||||
// 在 pageCurl 仿真翻页模式下,每个页面由一个 RDReaderPageChildViewController 管理,
|
||||
// 通过 contentContainerView 承载实际内容视图,并保持页码引用用于翻页状态追踪。
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第三层的子控制器组件,被 UIPageViewController 使用。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 仿真翻页模式下的页面子控制器
|
||||
/// 作为 UIPageViewController 的子控制器,每个实例管理一页内容。
|
||||
/// 持有 ``contentView``(实际内容视图)和 ``pageNum``(页码标识),
|
||||
/// 通过 contentContainerView 将内容视图填满控制器视图。
|
||||
class RDReaderPageChildViewController: UIViewController {
|
||||
/// 内容容器视图,作为 contentView 的父视图
|
||||
private let contentContainerView = UIView()
|
||||
|
||||
/// 实际内容视图
|
||||
/// 设置时如果视图已加载,会自动重新安装到 contentContainerView 中
|
||||
var contentView: UIView? {
|
||||
didSet {
|
||||
guard isViewLoaded else { return }
|
||||
installContentView()
|
||||
}
|
||||
}
|
||||
|
||||
/// 该控制器管理的页码标识
|
||||
var pageNum: Int = 0
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - contentView: 要显示的内容视图
|
||||
/// - pageNum: 该页的页码(默认0)
|
||||
init(contentView: UIView?, pageNum: Int = 0) {
|
||||
self.contentView = contentView
|
||||
self.pageNum = pageNum
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
/// 加载视图时调用
|
||||
/// 创建透明背景的根视图,添加 contentContainerView,并安装内容视图
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
@ -31,7 +54,7 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
view.addSubview(contentContainerView)
|
||||
installContentView()
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
@ -41,6 +64,8 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
/// 安装内容视图到 contentContainerView 中
|
||||
/// 先移除 contentContainerView 中的所有子视图,再将 contentView 添加并填满
|
||||
private func installContentView() {
|
||||
contentContainerView.subviews.forEach { $0.removeFromSuperview() }
|
||||
guard let contentView else { return }
|
||||
@ -49,7 +74,7 @@ class RDReaderPageChildViewController: UIViewController {
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentContainerView.addSubview(contentView)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
// MARK: - Navigation
|
||||
@ -4,32 +4,73 @@
|
||||
//
|
||||
// Created by yangsq on 2021/8/6.
|
||||
//
|
||||
// 文件职责:核心翻页容器视图,RDReaderView 模块的入口和中枢。
|
||||
// 该文件定义了阅读器的核心 UIView 子类 RDReaderView,负责:
|
||||
// 1. 管理四种翻页模式(仿真翻页、水平滚动、垂直滚动、水平覆盖滚动)
|
||||
// 2. 实现手势分区逻辑(左1/3上一页、中1/3工具栏、右1/3下一页)
|
||||
// 3. 支持横屏双页显示(含封面页独占和页码配对算法)
|
||||
// 4. 支持从左往右(LTR)和从右往左(RTL)两种翻页方向
|
||||
// 5. 提供 DataSource/Delegate 协议供上层控制器实现数据供给和事件回调
|
||||
// 6. 管理页面预加载和缓存机制
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中的第三层(翻页容器层)
|
||||
// EPUBCore(解析引擎)→ EPUBTextRendering(文本渲染)→ RDReaderView(翻页容器)→ EPUBUI(读者 UI)
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
|
||||
/// 阅读器数据源协议
|
||||
/// 上层控制器通过实现此协议,向 RDReaderView 提供页面数量、内容视图和工具栏。
|
||||
/// 所有方法由 RDReaderView 在需要渲染页面时回调。
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
/// 返回阅读器的总页数
|
||||
/// - Parameter readerView: 发起请求的阅读器视图
|
||||
/// - Returns: 总页数
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
/// 返回指定页码的内容视图
|
||||
/// - Parameters:
|
||||
/// - readerView: 发起请求的阅读器视图
|
||||
/// - pageNum: 页码(从0开始)
|
||||
/// - containerView: 可复用的容器视图(非 nil 时可复用以减少创建开销)
|
||||
/// - Returns: 该页的内容视图
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
/// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用
|
||||
/// - Parameters:
|
||||
/// - readerView: 发起请求的阅读器视图
|
||||
/// - pageNum: 页码
|
||||
/// - Returns: 页标识符字符串,nil 则使用默认 cell
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
/// 返回顶部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
/// 返回底部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
/// 阅读器代理协议
|
||||
/// 上层控制器通过实现此协议,接收翻页和横竖屏切换事件
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
/// 翻页回调,当当前显示页码变化时触发
|
||||
/// - Parameters:
|
||||
/// - readerView: 发起回调的阅读器视图
|
||||
/// - pageNum: 当前页码
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
/// 横竖屏切换时回调,可在此重新分页
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
extension RDReaderView {
|
||||
public enum DisplayType {
|
||||
/// 翻页模式枚举,决定 RDReaderView 使用哪种底层视图来展示内容
|
||||
public enum DisplayType {
|
||||
/// 仿真翻页:使用 UIPageViewController,模拟真实翻书效果
|
||||
case pageCurl
|
||||
/// 水平滚动:使用 UICollectionView + 自定义布局,每屏一项,水平分页
|
||||
case horizontalScroll
|
||||
/// 垂直滚动:使用 UICollectionView,全宽项目,垂直连续滚动
|
||||
case verticalScroll
|
||||
}
|
||||
|
||||
/// 翻页方向
|
||||
|
||||
/// 翻页方向枚举
|
||||
public enum PageDirection {
|
||||
/// 从左往右翻页(默认,适用于中文/英文书籍)
|
||||
case leftToRight
|
||||
@ -38,33 +79,66 @@ extension RDReaderView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 核心翻页容器视图
|
||||
/// RDReaderView 模块的核心类,负责管理四种翻页模式的切换、手势识别、
|
||||
/// 横屏双页显示、页面缓存预加载等核心功能。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 设置 ``dataSource`` 提供页面数据
|
||||
/// 2. 设置 ``delegate`` 接收翻页事件
|
||||
/// 3. 调用 ``switchReaderDisplayType(_:)`` 切换翻页模式
|
||||
/// 4. 调用 ``reloadData()`` 刷新数据
|
||||
///
|
||||
/// 支持的功能:
|
||||
/// - 四种翻页模式:pageCurl / horizontalScroll / verticalScroll
|
||||
/// - 横屏双页显示(含封面页独占逻辑)
|
||||
/// - RTL 翻页方向支持
|
||||
/// - 手势分区(左翻/工具栏/右翻)
|
||||
/// - 页面预加载和缓存
|
||||
public class RDReaderView: UIView {
|
||||
|
||||
|
||||
/// 屏幕点击区域枚举,用于手势分区逻辑
|
||||
/// 将屏幕水平三等分,分别响应不同的手势事件
|
||||
enum TapEvent {
|
||||
case none, left, center, right
|
||||
/// 无操作
|
||||
case none
|
||||
/// 左1/3区域:上一页(RTL 时为下一页)
|
||||
case left
|
||||
/// 中1/3区域:切换工具栏显示/隐藏
|
||||
case center
|
||||
/// 右1/3区域:下一页(RTL 时为上一页)
|
||||
case right
|
||||
}
|
||||
|
||||
|
||||
/// 仿真翻页模式使用的 UIPageViewController
|
||||
/// 通过 pageCurl 转场样式实现仿真翻书效果
|
||||
private lazy var pageViewController: UIPageViewController = {
|
||||
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
|
||||
pageVC.delegate = self
|
||||
pageVC.dataSource = self
|
||||
return pageVC
|
||||
}()
|
||||
|
||||
|
||||
/// 自定义流式布局,支持水平滚动、垂直滚动和水平覆盖滚动三种布局模式
|
||||
private lazy var layout: RDReaderFlowLayout = {
|
||||
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
|
||||
layout.dataSource = self
|
||||
layout.delegate = self
|
||||
return layout
|
||||
}()
|
||||
|
||||
|
||||
/// 滚动模式使用的 UICollectionView
|
||||
/// 用于 horizontalScroll 和 verticalScroll 两种翻页模式
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
|
||||
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.backgroundColor = UIColor.clear
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
|
||||
/// 当前显示的页码
|
||||
/// 值变化时会通过 delegate 回调通知上层控制器
|
||||
/// 初始值为 -1 表示尚未加载任何页面
|
||||
public var currentPage: Int = -1 {
|
||||
didSet {
|
||||
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) {
|
||||
@ -72,12 +146,16 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 全屏点击手势识别器
|
||||
/// 用于检测用户点击屏幕的区域,触发翻页或工具栏切换
|
||||
private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
|
||||
return tap
|
||||
}()
|
||||
|
||||
|
||||
/// 当前点击事件类型
|
||||
/// 值变化时会根据事件类型执行翻页或工具栏切换逻辑
|
||||
private var tapEvent: TapEvent = .none {
|
||||
didSet {
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
@ -97,7 +175,9 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 翻到下一页
|
||||
/// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 +1
|
||||
private func goNextPage() {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
if pagesPerScreen > 1 {
|
||||
@ -108,7 +188,9 @@ public class RDReaderView: UIView {
|
||||
transitionToPage(pageNum: currentPage + 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 翻到上一页
|
||||
/// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 -1
|
||||
private func goPreviousPage() {
|
||||
if pagesPerScreen > 1 {
|
||||
if let target = adjacentDualPage(from: currentPage, forward: false) {
|
||||
@ -118,36 +200,41 @@ public class RDReaderView: UIView {
|
||||
transitionToPage(pageNum: currentPage - 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 数据源代理,提供页面数量、内容视图和工具栏
|
||||
public weak var dataSource: RDReaderDataSource? = nil
|
||||
/// 代理,接收翻页和横竖屏切换事件
|
||||
public weak var delegate: RDReaderDelegate? = nil
|
||||
/// 当前翻页模式,默认为仿真翻页
|
||||
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
|
||||
/// 工具栏显示/隐藏动画时长,默认 0.3 秒
|
||||
public var toolViewAnimationDuration: TimeInterval = 0.3
|
||||
/// 是否启用横屏双页显示
|
||||
public var landscapeDualPageEnabled: Bool = false
|
||||
/// 翻页方向,默认从左往右(.leftToRight)
|
||||
/// 翻页方向,默认从左往右(适用于中文/英文书籍)
|
||||
public var pageDirection: RDReaderView.PageDirection = .leftToRight
|
||||
/// 横屏双页模式下,封面页的索引。设置后该页在横屏时独占一屏,后续页面两两配对。
|
||||
/// 设为 nil 表示没有封面页(所有页面正常两两配对)。
|
||||
/// 设为 nil 表示没有封面页(所有页面正常两两配对:0+1, 2+3, 4+5...)。
|
||||
public var coverPageIndex: Int? = nil
|
||||
|
||||
|
||||
/// 是否启用了封面页独占
|
||||
private var hasCoverPage: Bool {
|
||||
return coverPageIndex != nil
|
||||
}
|
||||
|
||||
/// 当前是否横屏
|
||||
|
||||
/// 当前是否横屏(宽度 > 高度)
|
||||
private var isLandscape: Bool {
|
||||
return bounds.width > bounds.height
|
||||
}
|
||||
|
||||
|
||||
/// 每屏显示的页数
|
||||
/// 未启用双页、垂直滚动模式或竖屏时返回1,横屏双页模式返回2
|
||||
public var pagesPerScreen: Int {
|
||||
if !landscapeDualPageEnabled { return 1 }
|
||||
if currentDisplayType == .verticalScroll { return 1 }
|
||||
return isLandscape ? 2 : 1
|
||||
}
|
||||
|
||||
|
||||
/// 判断某页在横屏双页模式下是否独占一屏(封面页)
|
||||
/// - Parameter pageNum: 页码
|
||||
/// - Returns: 是否独占一屏
|
||||
@ -155,11 +242,11 @@ public class RDReaderView: UIView {
|
||||
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
|
||||
return pageNum == coverIndex
|
||||
}
|
||||
|
||||
|
||||
/// 根据逻辑页码计算横屏双页模式下的配对信息
|
||||
/// 封面页独占一屏,后续页面两两配对
|
||||
/// - Parameter pageNum: 逻辑页码
|
||||
/// - Returns: (左页码, 右页码(可选))
|
||||
/// - Returns: (左页码, 右页码(可选,nil表示右页为空白))
|
||||
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
if let coverIndex = coverPageIndex {
|
||||
@ -181,11 +268,11 @@ public class RDReaderView: UIView {
|
||||
return (left, right)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 计算横屏双页下,某页往前/后翻一屏后的起始页码
|
||||
/// - Parameters:
|
||||
/// - pageNum: 当前页码
|
||||
/// - forward: 是否向后翻
|
||||
/// - forward: 是否向后翻(true=下一页,false=上一页)
|
||||
/// - Returns: 目标页码,nil 表示到头了
|
||||
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
@ -199,41 +286,65 @@ public class RDReaderView: UIView {
|
||||
return dualPagePair(for: prevEnd).left
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 上一次检测到的横竖屏状态,用于在 layoutSubviews 中检测方向变化
|
||||
private var previousIsLandscape: Bool?
|
||||
|
||||
|
||||
/// 已注册的内容视图类型字典,key 为重用标识符
|
||||
private var contentViews = [String : UIView.Type]()
|
||||
/// pageCurl 模式下,即将向前翻转到的子控制器
|
||||
private var willPreviousTransitionToViewController: UIViewController? = nil
|
||||
/// pageCurl 模式下,即将向后翻转到的子控制器
|
||||
private var willNextTransitionToViewController: UIViewController? = nil
|
||||
/// pageCurl 模式下,正在转场的目标控制器
|
||||
private var willTransitionToViewController: UIViewController? = nil
|
||||
/// 顶部工具栏视图
|
||||
private var topToolView: UIView?
|
||||
/// 底部工具栏视图
|
||||
private var bottomToolView: UIView?
|
||||
/// 工具栏是否正在显示
|
||||
private var isShowToolView: Bool = false
|
||||
/// 是否正在执行页面转场动画
|
||||
private var isTransitioning: Bool = false
|
||||
/// UI 是否已经构建完成(避免重复构建)
|
||||
private var didBuildUI = false
|
||||
/// 预加载宿主视图(隐藏不可见),用于存放预加载的页面内容
|
||||
private let preloadHostView = UIView()
|
||||
/// 预加载的页面视图缓存,key 为页码
|
||||
private var preloadedPageViews: [Int: UIView] = [:]
|
||||
/// pageCurl 模式下的页面视图缓存,key 为页码
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
/// 预测的翻页方向(true=向前,false=向后),用于优化预加载方向
|
||||
private var predictedPageDirection: Bool?
|
||||
/// 预加载半径:当前页前后各预加载几屏,默认 1 屏
|
||||
public var preloadRadius: Int = 1
|
||||
/// 缓存签名:用于检测布局环境是否变化,变化时需要清空缓存
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
/// 用于 pageCurl 双页模式下封面页旁边的空白页
|
||||
|
||||
/// 用于 pageCurl 双页模式下封面页旁边的空白页标识
|
||||
static let blankPageNum = Int.max
|
||||
/// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页
|
||||
/// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页标识
|
||||
static let blankEndPageNum = Int.max - 1
|
||||
|
||||
/// 缓存签名结构体
|
||||
/// 当这些参数中的任何一个发生变化时,缓存需要被清空并重建
|
||||
private struct CacheSignature: Equatable {
|
||||
/// 当前翻页模式
|
||||
let displayType: DisplayType
|
||||
/// 是否横屏
|
||||
let isLandscape: Bool
|
||||
/// 每屏页数
|
||||
let pagesPerScreen: Int
|
||||
/// 视图尺寸
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
||||
|
||||
/// 布局子视图时调用
|
||||
/// 检测横竖屏方向变化,触发相应的布局更新和页面重建
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
@ -252,8 +363,11 @@ public class RDReaderView: UIView {
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 横竖屏切换处理
|
||||
/// 根据当前翻页模式执行不同的重建策略:
|
||||
/// - pageCurl:重建 UIPageViewController(因为 spineLocation 只能在初始化时设置)
|
||||
/// - 滚动模式:禁用动画,重新加载数据并恢复滚动位置
|
||||
/// - Parameter isNowLandscape: 当前是否为横屏
|
||||
private func orientationChanged(isNowLandscape: Bool) {
|
||||
let savedPage = max(0, currentPage)
|
||||
@ -262,7 +376,7 @@ public class RDReaderView: UIView {
|
||||
// 2. 更新布局的横屏双页标记
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
|
||||
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
// 仿真翻页:通过 spineLocation 原生支持双页,需要重建 PageViewController
|
||||
@ -285,9 +399,10 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 创建带指定 spine 位置的 UIPageViewController
|
||||
/// - Parameter isDualPage: 是否双页模式(横屏时书脊在中间:.mid)
|
||||
/// - Returns: 配置好的 UIPageViewController 实例
|
||||
private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
|
||||
let options: [UIPageViewController.OptionsKey: Any]?
|
||||
if isDualPage {
|
||||
@ -301,8 +416,10 @@ public class RDReaderView: UIView {
|
||||
pageVC.isDoubleSided = isDualPage
|
||||
return pageVC
|
||||
}
|
||||
|
||||
/// 重建 UIPageViewController。横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置
|
||||
|
||||
/// 重建 UIPageViewController
|
||||
/// 横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置
|
||||
/// 先移除旧的 PageViewController,再创建新的并添加到父控制器
|
||||
private func rebuildPageViewController() {
|
||||
detachPageViewControllerIfNeeded()
|
||||
|
||||
@ -314,6 +431,10 @@ public class RDReaderView: UIView {
|
||||
|
||||
// MARK: - UIPageViewController Fault Detection
|
||||
|
||||
/// 检测 UIPageViewController 是否处于异常状态
|
||||
/// 异常状态包括:子控制器数量不对、页码不匹配等
|
||||
/// - Parameter pageVC: 要检测的 UIPageViewController
|
||||
/// - Returns: true 表示存在异常,需要修复
|
||||
private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
|
||||
guard currentDisplayType == .pageCurl else { return false }
|
||||
let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
|
||||
@ -338,6 +459,8 @@ public class RDReaderView: UIView {
|
||||
|| childViewControllers[1].pageNum != expectedRightPage
|
||||
}
|
||||
|
||||
/// 修复 UIPageViewController 异常状态
|
||||
/// 清空缓存、重建 PageViewController 并重新定位到当前页
|
||||
private func patchPageViewControllerFault() {
|
||||
guard currentPage >= 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -350,6 +473,8 @@ public class RDReaderView: UIView {
|
||||
|
||||
// MARK: - Preload / Forecast
|
||||
|
||||
/// 确保预加载宿主视图已添加到视图层级
|
||||
/// 该视图隐藏且不可交互,仅用于存放预加载的页面内容
|
||||
private func ensurePreloadHostView() {
|
||||
guard preloadHostView.superview == nil else { return }
|
||||
preloadHostView.isHidden = true
|
||||
@ -360,6 +485,9 @@ public class RDReaderView: UIView {
|
||||
insertSubview(preloadHostView, at: 0)
|
||||
}
|
||||
|
||||
/// 判断指定页码是否可以被缓存
|
||||
/// - Parameter pageNum: 页码
|
||||
/// - Returns: true 表示可以缓存(有效页码且在范围内)
|
||||
private func shouldCachePage(_ pageNum: Int) -> Bool {
|
||||
pageNum >= 0
|
||||
&& pageNum != RDReaderView.blankPageNum
|
||||
@ -367,6 +495,7 @@ public class RDReaderView: UIView {
|
||||
&& pageNum < (dataSource?.pageCountOfReaderView(readerView: self) ?? 0)
|
||||
}
|
||||
|
||||
/// 获取当前缓存签名,用于检测布局环境是否发生变化
|
||||
private func currentCacheSignature() -> CacheSignature {
|
||||
CacheSignature(
|
||||
displayType: currentDisplayType,
|
||||
@ -376,6 +505,8 @@ public class RDReaderView: UIView {
|
||||
)
|
||||
}
|
||||
|
||||
/// 清空所有页面缓存并更新缓存签名
|
||||
/// 在翻页模式切换、横竖屏切换时调用
|
||||
private func invalidatePageCaches() {
|
||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||
@ -384,6 +515,7 @@ public class RDReaderView: UIView {
|
||||
cacheSignature = currentCacheSignature()
|
||||
}
|
||||
|
||||
/// 检查缓存签名是否变化,变化则清空缓存
|
||||
private func refreshCacheSignatureIfNeeded() {
|
||||
let signature = currentCacheSignature()
|
||||
if cacheSignature != signature {
|
||||
@ -393,6 +525,8 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算指定页码所在"展开对"的起始页码
|
||||
/// 单页模式返回自身,双页模式返回配对的左页码
|
||||
private func spreadStart(for pageNum: Int) -> Int? {
|
||||
guard shouldCachePage(pageNum) else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
|
||||
@ -400,6 +534,8 @@ public class RDReaderView: UIView {
|
||||
return dualPagePair(for: pageNum).left
|
||||
}
|
||||
|
||||
/// 获取指定页码所在"展开对"的所有页码
|
||||
/// 单页模式返回自身,双页模式返回左右两页
|
||||
private func spreadPageNumbers(startingAt pageNum: Int) -> Set<Int> {
|
||||
guard shouldCachePage(pageNum) else { return [] }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
|
||||
@ -412,6 +548,7 @@ public class RDReaderView: UIView {
|
||||
return pages
|
||||
}
|
||||
|
||||
/// 计算相邻"展开对"的起始页码,用于预加载时计算前后相邻页
|
||||
private func adjacentSpreadStart(from pageNum: Int, forward: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
guard totalPages > 0,
|
||||
@ -435,10 +572,13 @@ public class RDReaderView: UIView {
|
||||
return dualPagePair(for: prevEnd).left
|
||||
}
|
||||
|
||||
/// 获取锚定页码对应的可见页码集合
|
||||
private func visiblePageNumbers(for anchorPage: Int) -> Set<Int> {
|
||||
spreadPageNumbers(startingAt: anchorPage)
|
||||
}
|
||||
|
||||
/// 获取用于显示的页面视图
|
||||
/// 优先从缓存中获取,其次从预加载缓存中获取,最后新建
|
||||
private func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||
if let cached = pageCurlCachedViews[pageNum] {
|
||||
return cached
|
||||
@ -453,6 +593,8 @@ public class RDReaderView: UIView {
|
||||
return view
|
||||
}
|
||||
|
||||
/// 裁剪缓存,只保留指定页码集合中的页面
|
||||
/// 不在保留集合中的页面会被从视图层级中移除
|
||||
private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
|
||||
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
@ -471,6 +613,9 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预测需要预加载的页码集合
|
||||
/// 基于当前页码和 preloadRadius,向前和向后各预测 preloadRadius 屏,
|
||||
/// 并根据 preferredForward 方向额外多预测一屏
|
||||
private func forecastTargets(around pageNum: Int, preferredForward: Bool?) -> [Int] {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
guard totalPages > 0, shouldCachePage(pageNum) else { return [] }
|
||||
@ -500,6 +645,11 @@ public class RDReaderView: UIView {
|
||||
return targets.sorted()
|
||||
}
|
||||
|
||||
/// 预加载核心方法
|
||||
/// 1. 检查缓存签名是否需要更新
|
||||
/// 2. 预测需要预加载的页码
|
||||
/// 3. 裁剪不在范围内的缓存
|
||||
/// 4. 创建或复用预加载视图
|
||||
private func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
||||
refreshCacheSignatureIfNeeded()
|
||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward)
|
||||
@ -521,7 +671,8 @@ public class RDReaderView: UIView {
|
||||
contentView.frame = preloadHostView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 视图被添加到父视图时调用,触发 UI 构建
|
||||
public override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
|
||||
@ -530,6 +681,8 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 pageViewController 添加到父控制器和视图层级
|
||||
/// 如果已在父控制器中则跳过,确保生命周期方法正确调用
|
||||
private func attachPageViewControllerIfNeeded() {
|
||||
guard let parentViewController = self.ss_superViewController else { return }
|
||||
|
||||
@ -555,6 +708,8 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从父控制器和视图层级中移除 pageViewController
|
||||
/// 确保 willMove/didMove 生命周期方法正确调用
|
||||
private func detachPageViewControllerIfNeeded() {
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
@ -564,7 +719,9 @@ public class RDReaderView: UIView {
|
||||
pageViewController.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 构建 UI 界面
|
||||
/// 首次调用时注册 cell、添加手势识别器、根据当前翻页模式添加对应视图
|
||||
private func makeUI() {
|
||||
guard !didBuildUI else {
|
||||
if currentDisplayType == .pageCurl {
|
||||
@ -584,13 +741,15 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
ensurePreloadHostView()
|
||||
cacheSignature = currentCacheSignature()
|
||||
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
// 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发
|
||||
tapGestureRecognizer.cancelsTouchesInView = false
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// 点击手势响应方法
|
||||
/// 根据点击位置将屏幕三等分,判断点击区域并设置 tapEvent
|
||||
@objc private func tapAction(tap: UITapGestureRecognizer) {
|
||||
let point = tap.location(in: tap.view)
|
||||
if isShowToolView {
|
||||
@ -609,11 +768,11 @@ public class RDReaderView: UIView {
|
||||
tapEvent = .left
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if centerFrame.contains(point) {
|
||||
tapEvent = .center
|
||||
}
|
||||
|
||||
|
||||
if rightFrame.contains(point) {
|
||||
if isShowToolView {
|
||||
tapEvent = .center
|
||||
@ -622,7 +781,10 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 点击屏幕中央区域,切换工具栏的显示/隐藏
|
||||
/// 显示时:工具栏从屏幕外滑入,禁用翻页交互
|
||||
/// 隐藏时:工具栏滑出屏幕,恢复翻页交互
|
||||
private func tapCenter() {
|
||||
isShowToolView = !isShowToolView
|
||||
if isShowToolView {
|
||||
@ -680,6 +842,7 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断点击命中的视图是否在指定工具栏内
|
||||
private func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if toolView.frame.contains(point) {
|
||||
return true
|
||||
@ -688,9 +851,10 @@ public class RDReaderView: UIView {
|
||||
guard let hitView else { return false }
|
||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||
}
|
||||
|
||||
|
||||
/// 切换翻页模式(仿真/水平滚动/上下滚动)
|
||||
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
let previousDisplayType = currentDisplayType
|
||||
self.currentDisplayType = displayType
|
||||
@ -727,8 +891,11 @@ public class RDReaderView: UIView {
|
||||
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// 支持所有翻页模式:
|
||||
/// - pageCurl:通过 UIPageViewController.setViewControllers 实现
|
||||
/// - 滚动模式:通过 setContentOffset 实现
|
||||
/// - Parameters:
|
||||
/// - pageNum: 目标页码(item 索引)
|
||||
/// - animated: 是否动画过渡
|
||||
@ -774,14 +941,16 @@ public class RDReaderView: UIView {
|
||||
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 重新加载数据
|
||||
/// 重新切换到当前翻页模式,并刷新工具栏
|
||||
public func reloadData() {
|
||||
switchReaderDisplayType(currentDisplayType)
|
||||
topToolView = self.dataSource?.topToolView?(readerView: self)
|
||||
bottomToolView = self.dataSource?.bottomToolView?(readerView: self)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
@ -790,8 +959,12 @@ public class RDReaderView: UIView {
|
||||
|
||||
|
||||
|
||||
/// UIPageViewController 数据源和代理实现
|
||||
/// 处理仿真翻页模式下的页面数据供给和翻页事件
|
||||
extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
|
||||
|
||||
/// 创建指定页码的子控制器
|
||||
/// 空白页返回空视图,正常页从缓存或数据源获取内容
|
||||
private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController {
|
||||
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
|
||||
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
|
||||
@ -799,16 +972,21 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
}
|
||||
|
||||
/// 计算某页的"下一页"页码(考虑封面页插入空白页)
|
||||
|
||||
/// 计算某页的"下一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
/// - Parameters:
|
||||
/// - pageNum: 当前页码
|
||||
/// - isDualPage: 是否双页模式
|
||||
/// - Returns: 下一页页码,nil 表示没有下一页
|
||||
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
|
||||
|
||||
// 末尾空白页之后没有更多页
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return RDReaderView.blankPageNum
|
||||
@ -818,12 +996,12 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
return firstContent < totalPages ? firstContent : nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let next = pageNum + 1
|
||||
if next < totalPages {
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
// pageNum 是最后一页,检查在双页模式下是否需要空白页配对
|
||||
if isDualPage {
|
||||
if let coverIndex = coverPageIndex {
|
||||
@ -841,8 +1019,13 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 计算某页的"上一页"页码(考虑封面页插入空白页)
|
||||
|
||||
/// 计算某页的"上一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
/// - Parameters:
|
||||
/// - pageNum: 当前页码
|
||||
/// - isDualPage: 是否双页模式
|
||||
/// - Returns: 上一页页码,nil 表示没有上一页
|
||||
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
// 末尾空白页的前一页是最后一个真实页
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
@ -860,38 +1043,44 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
let prev = pageNum - 1
|
||||
return prev >= 0 ? prev : nil
|
||||
}
|
||||
|
||||
|
||||
/// UIPageViewController 数据源:返回当前页之前(左/上)的页面控制器
|
||||
/// RTL 模式下 before/after 语义互换:before(向右翻)= 下一页
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
|
||||
// RTL 时 before/after 语义互换:before(向右翻)= 下一页
|
||||
let targetNum = isRTL
|
||||
? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
: prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willPreviousTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
|
||||
/// UIPageViewController 数据源:返回当前页之后(右/下)的页面控制器
|
||||
/// RTL 模式下 before/after 语义互换
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
|
||||
let targetNum = isRTL
|
||||
? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
: nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willNextTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
|
||||
/// UIPageViewController 代理:翻页动画完成回调
|
||||
/// 更新当前页码,触发预加载,并检测 PageViewController 异常状态
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
|
||||
let pn = firstVC.pageNum
|
||||
@ -907,7 +1096,9 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
patchPageViewControllerFault()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// UIPageViewController 代理:即将开始转场动画
|
||||
/// 记录预测的翻页方向,用于优化预加载策略
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
|
||||
@ -917,15 +1108,20 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// UICollectionView 数据源和自定义布局代理实现
|
||||
/// 处理水平滚动和垂直滚动两种翻页模式
|
||||
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
|
||||
/// 配置 UICollectionViewCell
|
||||
/// 通过 DataSource 获取页面内容视图,支持 cell 复用和预加载视图复用
|
||||
/// RTL 水平模式下翻转 cell 内容使文字方向正常
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
|
||||
|
||||
|
||||
|
||||
if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) {
|
||||
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
|
||||
let preloadedView = preloadedPageViews.removeValue(forKey: indexPath.row)
|
||||
preloadedView?.removeFromSuperview()
|
||||
@ -942,33 +1138,44 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate,
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
|
||||
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
/// 返回每组的 item 数量,即总页数
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return self.dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
}
|
||||
|
||||
|
||||
/// 布局代理回调:当前可见页码变化时通知
|
||||
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
|
||||
currentPage = pageIndex
|
||||
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
|
||||
}
|
||||
|
||||
|
||||
/// 布局数据源回调:返回垂直滚动模式下指定页的高度
|
||||
/// 当前返回 nil 使用默认高度
|
||||
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容视图注册和复用扩展
|
||||
/// 提供类似 UITableView 的 register/dequeueReusable 机制
|
||||
extension RDReaderView {
|
||||
/// 注册内容视图类型
|
||||
/// - Parameters:
|
||||
/// - contentView: 内容视图的类型
|
||||
/// - identifier: 重用标识符
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
|
||||
contentViews[identifier] = contentView
|
||||
collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 获取可复用的内容视图
|
||||
/// 优先从当前显示的 cell 中获取,其次创建新实例
|
||||
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
|
||||
if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView {
|
||||
return containerView
|
||||
@ -978,7 +1185,9 @@ extension RDReaderView {
|
||||
var contentView = contentViewClass!.init()
|
||||
return contentView
|
||||
}
|
||||
|
||||
|
||||
/// 获取指定页码的内容视图
|
||||
/// pageCurl 模式从 PageViewController 获取,滚动模式从 CollectionView cell 获取
|
||||
public func pageContentView(pageNum: Int) -> UIView? {
|
||||
if currentDisplayType == .pageCurl {
|
||||
return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView
|
||||
@ -990,7 +1199,11 @@ extension RDReaderView {
|
||||
}
|
||||
|
||||
private var cellViewKey: Int8 = 0
|
||||
|
||||
/// UIView 扩展:通过响应链查找最近的父 UIViewController
|
||||
extension UIView {
|
||||
/// 沿响应链向上查找最近的 UIViewController
|
||||
/// 用于在视图中获取其所在控制器的引用
|
||||
var ss_superViewController: UIViewController? {
|
||||
var next = self.next
|
||||
while next != nil {
|
||||
@ -1002,5 +1215,5 @@ extension UIView {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user