ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

338 lines
10 KiB
Swift

import UIKit
public extension NSAttributedString.Key {
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
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")
}
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph
case list
case table
case code
case blockquote
case attachment
case generic
}
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside
case keepWithNext
case pageBreakBefore
case pageBreakAfter
case pageRelate
}
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline
case baseline
case centered
}
public struct RDEPUBTextRenderStyle {
public var font: UIFont
public var lineSpacing: CGFloat
public var textColor: UIColor?
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
}
}
public struct RDEPUBTextLayoutConfig: Equatable {
public var frameWidth: CGFloat
public var frameHeight: CGFloat
public var edgeInsets: UIEdgeInsets
public var numberOfColumns: Int
public var columnGap: CGFloat
public var avoidOrphans: Bool
public var avoidWidows: Bool
public var avoidPageBreakInsideEnabled: Bool
public var hyphenation: Bool
public var imageMaxHeightRatio: CGFloat
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()
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
CGSize(
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
)
}
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: "|")
}
}
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default`
case replace
case dark
case epub
case user
}
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
public init(kind: RDEPUBTextStyleSheetLayerKind, css: String) {
self.kind = kind
self.css = css
}
}
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
public init(layers: [RDEPUBTextStyleSheetLayer]) {
self.layers = layers
}
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")
}
}
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
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
}
}
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 var styleCompatibilityReport: RDEPUBCSSCompatibilityReport
public init(
href: String,
title: String,
html: String,
baseURL: URL?,
stylesheet: RDEPUBTextStyleSheetPackage,
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic],
styleCompatibilityReport: RDEPUBCSSCompatibilityReport = RDEPUBCSSCompatibilityReport()
) {
self.href = href
self.title = title
self.html = html
self.baseURL = baseURL
self.stylesheet = stylesheet
self.resourceDiagnostics = resourceDiagnostics
self.styleCompatibilityReport = styleCompatibilityReport
}
}
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
public var pageSize: CGSize?
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
}
}
public struct RDEPUBRenderedChapterContent {
public var attributedString: NSAttributedString
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
}
}
public protocol RDEPUBTextRenderer {
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
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))
}
}
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed
case htmlImportFailed
public var errorDescription: String? {
switch self {
case .htmlEncodingFailed:
return "HTML 编码失败"
case .htmlImportFailed:
return "HTML 富文本导入失败"
}
}
}