383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
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 // <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 // 微信读书式跨页关联元素
|
||
}
|
||
|
||
// MARK: - 附件布局方式枚举
|
||
|
||
/// 富文本附件(图片等)的垂直布局方式。
|
||
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
|
||
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) {
|
||
self.font = font
|
||
self.lineSpacing = lineSpacing
|
||
self.textColor = textColor
|
||
self.backgroundColor = backgroundColor
|
||
}
|
||
}
|
||
|
||
// MARK: - 布局配置
|
||
|
||
/// 分页引擎的布局控制参数。
|
||
public struct RDEPUBTextLayoutConfig: Equatable {
|
||
/// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width
|
||
public var frameWidth: CGFloat
|
||
/// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height
|
||
public var frameHeight: CGFloat
|
||
/// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets
|
||
public var edgeInsets: UIEdgeInsets
|
||
/// 栏数,对标 WXRead 的 numberOfColumns
|
||
public var numberOfColumns: Int
|
||
/// 栏间距,对标 WXRead 的 columnGap
|
||
public var columnGap: CGFloat
|
||
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
||
public var avoidOrphans: Bool
|
||
/// 是否避免寡行(段落第一行单独在上一页底部)
|
||
public var avoidWidows: Bool
|
||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||
public var avoidPageBreakInsideEnabled: Bool
|
||
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
|
||
public var hyphenation: Bool
|
||
/// 图片最大高度占页面高度的比例
|
||
public var imageMaxHeightRatio: CGFloat
|
||
|
||
public init(
|
||
frameWidth: CGFloat = 0,
|
||
frameHeight: CGFloat = 0,
|
||
edgeInsets: UIEdgeInsets = .zero,
|
||
numberOfColumns: Int = 1,
|
||
columnGap: CGFloat = 20,
|
||
avoidOrphans: Bool = true,
|
||
avoidWidows: Bool = true,
|
||
avoidPageBreakInsideEnabled: Bool = true,
|
||
hyphenation: Bool = true,
|
||
imageMaxHeightRatio: CGFloat = 0.85
|
||
) {
|
||
self.frameWidth = frameWidth
|
||
self.frameHeight = frameHeight
|
||
self.edgeInsets = edgeInsets
|
||
self.numberOfColumns = max(1, numberOfColumns)
|
||
self.columnGap = max(0, columnGap)
|
||
self.avoidOrphans = avoidOrphans
|
||
self.avoidWidows = avoidWidows
|
||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||
self.hyphenation = hyphenation
|
||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||
}
|
||
|
||
/// 默认配置
|
||
public static let `default` = RDEPUBTextLayoutConfig()
|
||
|
||
/// 结合调用方 pageSize 解析后的实际页面尺寸。
|
||
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
|
||
CGSize(
|
||
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
|
||
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
|
||
)
|
||
}
|
||
|
||
/// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。
|
||
public func contentRect(fallback pageSize: CGSize) -> CGRect {
|
||
let size = resolvedFrameSize(fallback: pageSize)
|
||
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
|
||
}
|
||
|
||
/// 多栏布局时的列矩形数组。
|
||
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
|
||
let rect = contentRect(fallback: pageSize)
|
||
let columns = max(1, numberOfColumns)
|
||
guard columns > 1 else { return [rect] }
|
||
|
||
let totalGap = CGFloat(columns - 1) * columnGap
|
||
let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
|
||
|
||
return (0..<columns).map { index in
|
||
let originX = rect.minX + CGFloat(index) * (columnWidth + columnGap)
|
||
return CGRect(x: originX, y: rect.minY, width: columnWidth, height: rect.height)
|
||
}
|
||
}
|
||
|
||
/// 持久化/缓存键使用的稳定签名。
|
||
public var cacheSignature: String {
|
||
[
|
||
String(format: "%.3f", frameWidth),
|
||
String(format: "%.3f", frameHeight),
|
||
String(format: "%.3f", edgeInsets.top),
|
||
String(format: "%.3f", edgeInsets.left),
|
||
String(format: "%.3f", edgeInsets.bottom),
|
||
String(format: "%.3f", edgeInsets.right),
|
||
String(numberOfColumns),
|
||
String(format: "%.3f", columnGap),
|
||
avoidOrphans ? "1" : "0",
|
||
avoidWidows ? "1" : "0",
|
||
avoidPageBreakInsideEnabled ? "1" : "0",
|
||
hyphenation ? "1" : "0",
|
||
String(format: "%.3f", imageMaxHeightRatio)
|
||
].joined(separator: "|")
|
||
}
|
||
}
|
||
|
||
// MARK: - CSS 样式表层级
|
||
|
||
/// 样式表层级类型,用于 CSS 层叠优先级管理。
|
||
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
|
||
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
|
||
|
||
public init(kind: RDEPUBTextStyleSheetLayerKind, css: String) {
|
||
self.kind = kind
|
||
self.css = css
|
||
}
|
||
}
|
||
|
||
/// CSS 样式表包,管理多层 CSS 的合并与注入顺序。
|
||
///
|
||
/// CSS 层叠顺序(从低到高):default → replace → dark → epub → user
|
||
public struct RDEPUBTextStyleSheetPackage: Equatable {
|
||
public var layers: [RDEPUBTextStyleSheetLayer]
|
||
|
||
public init(layers: [RDEPUBTextStyleSheetLayer]) {
|
||
self.layers = layers
|
||
}
|
||
|
||
/// 合并所有非空层的 CSS,每层添加注释头标记
|
||
public var combinedCSS: String {
|
||
layers
|
||
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||
.map { layer in
|
||
"/* \(layer.kind.rawValue) */\n\(layer.css)"
|
||
}
|
||
.joined(separator: "\n\n")
|
||
}
|
||
}
|
||
|
||
// MARK: - 资源引用诊断
|
||
|
||
/// 资源引用类型(样式表或图片)
|
||
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
|
||
case stylesheet
|
||
case image
|
||
}
|
||
|
||
/// 单个资源引用的诊断信息,用于检测 EPUB 中的资源是否正确引用。
|
||
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
|
||
public var kind: RDEPUBTextResourceReferenceKind
|
||
public var chapterHref: String
|
||
public var originalReference: String
|
||
public var normalizedHref: String?
|
||
public var resolvedFileURL: URL?
|
||
public var existsOnDisk: Bool
|
||
|
||
public init(
|
||
kind: RDEPUBTextResourceReferenceKind,
|
||
chapterHref: String,
|
||
originalReference: String,
|
||
normalizedHref: String?,
|
||
resolvedFileURL: URL?,
|
||
existsOnDisk: Bool
|
||
) {
|
||
self.kind = kind
|
||
self.chapterHref = chapterHref
|
||
self.originalReference = originalReference
|
||
self.normalizedHref = normalizedHref
|
||
self.resolvedFileURL = resolvedFileURL
|
||
self.existsOnDisk = existsOnDisk
|
||
}
|
||
}
|
||
|
||
// MARK: - 章节渲染上下文与请求
|
||
|
||
/// 章节渲染上下文:包含 HTML 源码、样式表和资源诊断信息。
|
||
public struct RDEPUBTextChapterContext: Equatable {
|
||
public var href: String
|
||
public var title: String
|
||
public var html: String
|
||
public var baseURL: URL?
|
||
public var stylesheet: RDEPUBTextStyleSheetPackage
|
||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||
|
||
public init(
|
||
href: String,
|
||
title: String,
|
||
html: String,
|
||
baseURL: URL?,
|
||
stylesheet: RDEPUBTextStyleSheetPackage,
|
||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||
) {
|
||
self.href = href
|
||
self.title = title
|
||
self.html = html
|
||
self.baseURL = baseURL
|
||
self.stylesheet = stylesheet
|
||
self.resourceDiagnostics = resourceDiagnostics
|
||
}
|
||
}
|
||
|
||
/// 章节渲染请求:打包上下文和渲染样式,传递给 `RDEPUBTextRenderer`。
|
||
public struct RDEPUBTextChapterRenderRequest {
|
||
public var context: RDEPUBTextChapterContext
|
||
public var style: RDEPUBTextRenderStyle
|
||
/// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。
|
||
public var pageSize: CGSize?
|
||
/// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。
|
||
public var layoutConfig: RDEPUBTextLayoutConfig?
|
||
|
||
public init(
|
||
context: RDEPUBTextChapterContext,
|
||
style: RDEPUBTextRenderStyle,
|
||
pageSize: CGSize? = nil,
|
||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
||
) {
|
||
self.context = context
|
||
self.style = style
|
||
self.pageSize = pageSize
|
||
self.layoutConfig = layoutConfig
|
||
}
|
||
}
|
||
|
||
// MARK: - 渲染结果
|
||
|
||
/// 章节渲染的输出结果,包含富文本、fragment 偏移量和资源诊断。
|
||
public struct RDEPUBRenderedChapterContent {
|
||
/// 渲染后的富文本
|
||
public var attributedString: NSAttributedString
|
||
/// fragment ID → 字符偏移量映射
|
||
public var fragmentOffsets: [String: Int]
|
||
/// 资源引用诊断列表
|
||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||
|
||
public init(
|
||
attributedString: NSAttributedString,
|
||
fragmentOffsets: [String: Int],
|
||
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
|
||
) {
|
||
self.attributedString = attributedString
|
||
self.fragmentOffsets = fragmentOffsets
|
||
self.resourceDiagnostics = resourceDiagnostics
|
||
}
|
||
}
|
||
|
||
// MARK: - 渲染器协议
|
||
|
||
/// EPUB 文本渲染器协议,定义 HTML → NSAttributedString 的转换接口。
|
||
///
|
||
/// 默认实现:`RDEPUBDTCoreTextRenderer`(基于 DTCoreText 库)
|
||
public protocol RDEPUBTextRenderer {
|
||
/// 渲染单个章节
|
||
func renderChapter(
|
||
request: RDEPUBTextChapterRenderRequest
|
||
) throws -> RDEPUBRenderedChapterContent
|
||
|
||
/// 便捷方法:直接渲染 HTML 字符串
|
||
func renderChapter(
|
||
html: String,
|
||
baseURL: URL?,
|
||
style: RDEPUBTextRenderStyle
|
||
) throws -> RDEPUBRenderedChapterContent
|
||
}
|
||
|
||
/// 协议默认实现:将便捷方法委托给完整方法
|
||
public extension RDEPUBTextRenderer {
|
||
func renderChapter(
|
||
html: String,
|
||
baseURL: URL?,
|
||
style: RDEPUBTextRenderStyle
|
||
) throws -> RDEPUBRenderedChapterContent {
|
||
let context = RDEPUBTextChapterContext(
|
||
href: "",
|
||
title: "",
|
||
html: html,
|
||
baseURL: baseURL,
|
||
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
|
||
resourceDiagnostics: []
|
||
)
|
||
return try renderChapter(request: RDEPUBTextChapterRenderRequest(context: context, style: style))
|
||
}
|
||
}
|
||
|
||
// MARK: - 渲染错误
|
||
|
||
/// EPUB 文本渲染过程中可能出现的错误类型。
|
||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||
case htmlEncodingFailed // HTML 字符串编码为 Data 失败
|
||
case htmlImportFailed // HTML 富文本解析失败
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .htmlEncodingFailed:
|
||
return "HTML 编码失败"
|
||
case .htmlImportFailed:
|
||
return "HTML 富文本导入失败"
|
||
}
|
||
}
|
||
}
|