Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
T

389 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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
/// 当调用方暂时拿不到 pageSize 时,用于估算附件尺寸的兜底 viewport。
public var fallbackViewportSize: CGSize
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,
fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
) {
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
self.fallbackViewportSize = fallbackViewportSize
}
/// 默认配置
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),
String(format: "%.3f", fallbackViewportSize.width),
String(format: "%.3f", fallbackViewportSize.height)
].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 富文本导入失败"
}
}
}