Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
T
2026-05-26 21:08:27 +08:00

2076 lines
80 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: - 文件说明
/// 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
let safeTop: CGFloat
let safeLeft: CGFloat
let safeBottom: CGFloat
let safeRight: CGFloat
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
abs(width - other.width) > threshold ||
abs(height - other.height) > threshold ||
abs(safeTop - other.safeTop) > threshold ||
abs(safeLeft - other.safeLeft) > threshold ||
abs(safeBottom - other.safeBottom) > threshold ||
abs(safeRight - other.safeRight) > threshold
}
}
/// 视口变化的原因枚举,用于决定延迟处理的策略
private enum RDEPUBViewportChangeReason {
case viewLayout
case orientationTransition
case pendingRepagination
}
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
// MARK: - 公开属性
/// 阅读器委托,用于接收阅读状态变更通知
public weak var delegate: RDEPUBReaderDelegate?
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
public var configuration: RDEPUBReaderConfiguration {
didSet {
persistReaderSettingsIfNeeded()
guard isViewLoaded else { return }
let oldConfiguration = oldValue
applyReaderViewConfiguration()
if isExternalTextBook {
if requiresRepagination(from: oldConfiguration, to: configuration) {
rebuildExternalTextBook()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
return
}
guard publication != nil else { return }
if requiresRepagination(from: oldConfiguration, to: configuration) {
repaginatePreservingCurrentLocation()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
}
}
/// 当前阅读位置
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
}
/// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
/// 原始目录树结构
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()
private let loadingIndicator: UIActivityIndicatorView = {
if #available(iOS 13.0, *) {
return UIActivityIndicatorView(style: .large)
}
return UIActivityIndicatorView(style: .gray)
}()
private let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .darkGray
label.isHidden = true
return label
}()
private let paginationHostView = UIView()
private var parser: RDEPUBParser?
fileprivate var publication: RDEPUBPublication?
private var readingSession: RDEPUBReadingSession?
private var textBook: RDEPUBTextBook?
private var activePages: [EPUBPage] = []
private var activeChapters: [EPUBChapterInfo] = []
fileprivate var activeBookmarks: [RDEPUBBookmark] = []
private var activeHighlights: [RDEPUBHighlight] = []
fileprivate lazy var topToolView = makeTopToolView()
fileprivate lazy var bottomToolView = makeBottomToolView()
fileprivate var currentBookIdentifier: String?
private let textBookCache = RDEPUBTextBookCache()
private var currentBrightness: CGFloat
private var didStartInitialLoad = false
private var isRepaginating = false
private var lastTextPaginationPageSize: CGSize?
private var isReconcilingTextPaginationSize = false
private var paginationToken = UUID()
private var paginator: RDEPUBPaginator?
private var searchState: RDEPUBSearchState?
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
private var pendingPresentationRestoreLocation: RDEPUBLocation?
private var isWaitingForViewportTransitionCompletion = false
/// 标记是否通过外部 TextBook 初始化(跳过 EPUB 解析流程)
private var isExternalTextBook = false
/// 外部 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,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence()
) {
let persistedSettings = persistence?.loadReaderSettings()
self.epubURL = epubURL
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
self.persistence = persistence
self.currentBrightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness)))
super.init(nibName: nil, bundle: nil)
UIScreen.main.brightness = currentBrightness
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
/// - Parameters:
/// - textBook: 已分页的 TextBook 实例
/// - bookIdentifier: 书籍唯一标识符,用于持久化
/// - title: 书籍标题
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
/// - configuration: 阅读器配置
/// - persistence: 持久化策略
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
title: String,
textFileURL: URL? = nil,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence()
) {
let persistedSettings = persistence?.loadReaderSettings()
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
let brightness = max(0, min(1, persistedSettings?.brightness ?? CGFloat(UIScreen.main.brightness)))
self.init(epubURL: URL(string: "about:blank")!, configuration: resolvedConfig, persistence: persistence)
self.isExternalTextBook = true
self.textBook = textBook
self.textFileURL = textFileURL
self.currentBookIdentifier = bookIdentifier
self.title = title
self.didStartInitialLoad = true
}
public override func viewDidLoad() {
super.viewDidLoad()
UIScreen.main.brightness = currentBrightness
view.backgroundColor = configuration.theme.contentBackgroundColor
setupReaderView()
setupLoadingIndicator()
setupErrorLabel()
delegate?.epubReader(self, configureTopToolView: topToolView)
if isExternalTextBook {
let restoreLocation = currentBookIdentifier.flatMap { persistence?.loadLocation(for: $0) }
if let id = currentBookIdentifier {
activeBookmarks = persistence?.loadBookmarks(for: id) ?? []
activeHighlights = persistence?.loadHighlights(for: id) ?? []
}
finishPagination(restoreLocation: restoreLocation)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let viewportSignature = currentViewportSignature() else { return }
if !didStartInitialLoad {
lastAppliedViewportSignature = viewportSignature
startInitialLoadIfNeeded()
return
}
guard publication != nil || isExternalTextBook else {
lastAppliedViewportSignature = viewportSignature
return
}
guard !isWaitingForViewportTransitionCompletion else {
return
}
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
guard didStartInitialLoad else { return }
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
isWaitingForViewportTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self else { return }
self.isWaitingForViewportTransitionCompletion = false
self.view.layoutIfNeeded()
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
}
}
// MARK: - 公开 API
/// 重新加载书籍,重置所有状态并重新解析 EPUB
public func reloadBook() {
didStartInitialLoad = false
parser = nil
publication = nil
readingSession = nil
textBook = nil
activePages = []
activeChapters = []
activeBookmarks = []
activeHighlights = []
searchState = nil
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
pendingPresentationRestoreLocation = nil
isWaitingForViewportTransitionCompletion = false
updateCurrentSelection(nil)
readerView.reloadData()
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 }
if textBook != nil {
guard let location = resolvedTextLocation(forPageNumber: pageNumber) else {
return false
}
return restoreReadingLocation(location, animated: animated)
}
guard activePages.indices.contains(pageNumber - 1) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = currentVisibleLocation() {
persist(location: location)
}
return true
}
/// 清除当前文本选中状态
public func clearSelection() {
updateCurrentSelection(nil)
}
public func bookmark(withID id: String) -> RDEPUBBookmark? {
activeBookmarks.first { $0.id == id }
}
public func highlight(withID id: String) -> RDEPUBHighlight? {
activeHighlights.first { $0.id == id }
}
public func nativeTextSemanticSummary() -> String? {
guard let textBook,
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
return nil
}
let metadata = page.metadata
var parts = [
"page \(page.absolutePageIndex + 1)",
"break \(metadata.breakReason.rawValue)",
metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]",
metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]",
metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let firstDiagnostic = metadata.diagnostics.first {
parts.append(firstDiagnostic)
}
return parts.joined(separator: " · ")
}
/// 添加高亮标注,从当前选中文本或指定选区创建
/// - Parameters:
/// - selection: 文本选区,默认使用 currentSelection
/// - color: 高亮颜色(CSS 格式),默认黄色
/// - note: 可选批注文字
/// - Returns: 创建的高亮对象,重复时返回 nil
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
}
@discardableResult
public func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
let sourceSelection = selection ?? currentSelection
guard let sourceSelection,
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
!scopedSelection.isEmpty else {
return nil
}
let newHighlight = RDEPUBHighlight(
bookIdentifier: currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: style,
color: color,
note: note
)
let isDuplicate = activeHighlights.contains { highlight in
highlight.location.href == newHighlight.location.href &&
highlight.location.fragment == newHighlight.location.fragment &&
highlight.text == newHighlight.text &&
highlight.rangeInfo == newHighlight.rangeInfo &&
highlight.style == newHighlight.style
}
guard !isDuplicate else {
return nil
}
activeHighlights.append(newHighlight)
persistHighlightsAndRefreshContent()
updateCurrentSelection(nil)
return newHighlight
}
@discardableResult
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let scopedHighlight = scopedHighlight(highlight) else {
return nil
}
if let index = activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
activeHighlights[index] = scopedHighlight
} else {
activeHighlights.append(scopedHighlight)
}
persistHighlightsAndRefreshContent()
return scopedHighlight
}
@discardableResult
public func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = activeHighlights.remove(at: index)
persistHighlightsAndRefreshContent()
return removed
}
@discardableResult
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let index = activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
activeHighlights[index].note = normalizedNote(note)
persistHighlightsAndRefreshContent()
return activeHighlights[index]
}
@discardableResult
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let highlight = highlight(withID: id) else {
return false
}
return restoreReadingLocation(highlight.location, animated: animated)
}
public func removeAllHighlights() {
guard !activeHighlights.isEmpty else { return }
activeHighlights.removeAll()
persistHighlightsAndRefreshContent()
}
@discardableResult
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
guard publication != nil else { return false }
let components = href.components(separatedBy: "#")
let baseHref = components.first ?? href
let fragment = components.count > 1 ? components[1] : nil
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: baseHref,
progression: 0,
fragment: fragment
)
return restoreReadingLocation(location, animated: animated)
}
private func setupReaderView() {
readerView.dataSource = self
readerView.delegate = self
readerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(readerView)
NSLayoutConstraint.activate([
readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
readerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
readerView.topAnchor.constraint(equalTo: view.topAnchor),
readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
applyReaderViewConfiguration()
}
private func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
self?.handleBackAction()
}
toolView.onToggleBookmark = { [weak self] in
_ = self?.toggleBookmark()
}
return toolView
}
private func makeBottomToolView() -> RDEPUBReaderBottomToolView {
let toolView = RDEPUBReaderBottomToolView()
toolView.onShowTableOfContents = { [weak self] in
self?.presentTableOfContents()
}
toolView.onShowBookmarks = { [weak self] in
self?.presentBookmarksManager()
}
toolView.onShowHighlights = { [weak self] in
self?.presentHighlightsManager()
}
toolView.onAddHighlight = { [weak self] in
self?.presentAnnotationCreation()
}
toolView.onShowSettings = { [weak self] in
self?.presentSettings()
}
return toolView
}
private func setupLoadingIndicator() {
loadingIndicator.hidesWhenStopped = true
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loadingIndicator)
NSLayoutConstraint.activate([
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
private func setupErrorLabel() {
errorLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(errorLabel)
NSLayoutConstraint.activate([
errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
private func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|| readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange
? (pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation())
: nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.pageDirection = resolvedDirection
updateReaderChrome()
if presentationDidChange {
readerView.switchReaderDisplayType(configuration.displayType)
if let preservedLocation {
_ = restoreReadingLocation(preservedLocation)
}
pendingPresentationRestoreLocation = nil
}
}
private func startInitialLoadIfNeeded() {
guard !didStartInitialLoad, readerView.bounds.width > 0, readerView.bounds.height > 0 else {
return
}
didStartInitialLoad = true
loadPublication()
}
private func loadPublication() {
showLoading()
let loadToken = UUID()
paginationToken = loadToken
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
let parser = RDEPUBParser()
do {
try parser.parse(epubURL: self.epubURL)
let publication = parser.makePublication()
let bookIdentifier = parser.metadata.identifier ?? self.epubURL.lastPathComponent
let restoreLocation = self.persistence?.loadLocation(for: bookIdentifier)
let bookmarks = self.persistence?.loadBookmarks(for: bookIdentifier) ?? []
let highlights = self.persistence?.loadHighlights(for: bookIdentifier) ?? []
DispatchQueue.main.async {
guard self.paginationToken == loadToken else { return }
self.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
} catch {
DispatchQueue.main.async {
guard self.paginationToken == loadToken else { return }
self.handle(error: error)
}
}
}
}
private func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
self.parser = parser
self.publication = publication
self.currentBookIdentifier = bookIdentifier
self.activeBookmarks = bookmarks
self.activeHighlights = highlights
self.readingSession = RDEPUBReadingSession(publication: publication)
self.readingSession?.transition(to: .loading)
self.title = parser.metadata.title.isEmpty ? epubURL.deletingPathExtension().lastPathComponent : parser.metadata.title
applyReaderViewConfiguration()
updateReaderChrome()
delegate?.epubReader(self, didOpen: publication)
paginatePublication(restoreLocation: restoreLocation)
}
private func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let parser, let publication, let readingSession else { return }
isRepaginating = true
errorLabel.isHidden = true
showLoading()
let token = UUID()
paginationToken = token
if publication.readingProfile == .textReflowable {
let renderer = resolvedTextRenderer()
let pageSize = currentTextPageSize()
lastTextPaginationPageSize = pageSize
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
let renderStyle = currentTextRenderStyle()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
do {
let textBook = try builder.build(
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle
)
DispatchQueue.main.async {
guard self.paginationToken == token else { return }
self.applyTextBook(textBook, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.paginationToken == token else { return }
self.handle(error: error)
}
}
}
return
}
if publication.layout == .fixed {
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: currentPreferences(),
layoutContext: currentLayoutContext()
)
applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
return
}
let paginator = RDEPUBPaginator()
self.paginator = paginator
paginator.calculate(
parser: parser,
hostingView: ensurePaginationHostView(),
presentation: currentPreferences().presentationStyle(viewportSize: currentLayoutContext().viewportSize)
) { [weak self] pageCounts in
guard let self = self, self.paginationToken == token else { return }
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: pageCounts,
preferences: self.currentPreferences(),
layoutContext: self.currentLayoutContext()
)
self.paginator = nil
self.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
private func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
self.textBook = textBook
let snapshot = nativeTextSnapshot(from: textBook)
self.activePages = snapshot.pages
self.activeChapters = snapshot.chapters
readingSession?.setActiveSnapshot(snapshot)
guard !textBook.pages.isEmpty else {
handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
private func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
textBook = nil
activePages = snapshot.pages
activeChapters = snapshot.chapters
readingSession?.setActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
private func finishPagination(restoreLocation: RDEPUBLocation?) {
isRepaginating = false
hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
restoreReadingLocation(targetLocation)
} else {
readerView.transitionToPage(pageNum: 0)
readingSession?.transition(to: .idle)
}
if pendingViewportChangeReason != nil {
let pendingReason = pendingViewportChangeReason ?? .pendingRepagination
pendingViewportChangeReason = nil
DispatchQueue.main.async { [weak self] in
self?.handleViewportChangeIfNeeded(reason: pendingReason)
}
}
}
private func repaginatePreservingCurrentLocation() {
guard publication != nil else { return }
let restoreLocation = pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation()
pendingPresentationRestoreLocation = nil
paginatePublication(restoreLocation: restoreLocation)
}
@discardableResult
fileprivate func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
guard let targetPageNumber = pageNumber(for: location) else {
readerView.transitionToPage(pageNum: 0)
readingSession?.transition(to: .idle)
return false
}
if textBook == nil {
_ = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
} else {
readingSession?.transition(to: .jumping)
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
return true
}
fileprivate func currentVisibleLocation() -> RDEPUBLocation? {
if textBook != nil, readerView.currentPage >= 0 {
return resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
}
return readingSession?.currentReadingLocation(bookIdentifier: currentBookIdentifier)
}
private func persistenceLocation() -> RDEPUBLocation? {
guard let currentBookIdentifier else { return nil }
return persistence?.loadLocation(for: currentBookIdentifier)
}
private func persist(location: RDEPUBLocation) {
guard let currentBookIdentifier else { return }
persistence?.saveLocation(location, for: currentBookIdentifier)
delegate?.epubReader(self, didUpdateLocation: location)
delegate?.epubReader(self, didUpdateCurrentTableOfContentsItem: resolvedCurrentTableOfContentsItem())
updateBookmarkChrome()
}
private func updateCurrentSelection(_ selection: RDEPUBSelection?) {
currentSelection = selection?.isEmpty == false ? selection : nil
bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil)
delegate?.epubReader(self, didChangeSelection: currentSelection)
}
private func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
selection.location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: selection.location.href,
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor
)
return RDEPUBSelection(
bookIdentifier: currentBookIdentifier,
location: normalizedLocation,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
highlight.location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: highlight.location.href,
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor
)
return RDEPUBHighlight(
id: highlight.id,
bookIdentifier: currentBookIdentifier,
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
style: highlight.style,
color: highlight.color,
note: highlight.note,
createdAt: highlight.createdAt
)
}
private func persistHighlightsAndRefreshContent() {
guard let currentBookIdentifier else { return }
persistence?.saveHighlights(activeHighlights, for: currentBookIdentifier)
delegate?.epubReader(self, didUpdateHighlights: activeHighlights)
updateReaderChrome()
refreshVisibleContentPreservingLocation()
}
private func refreshVisibleContentPreservingLocation() {
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
readerView.reloadData()
if let restoreLocation {
_ = restoreReadingLocation(restoreLocation)
}
}
private func rebuildExternalTextBook() {
guard let textFileURL else { return }
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
let pageSize = currentTextPageSize()
let style = currentTextRenderStyle()
let builder = RDPlainTextBookBuilder(layoutConfig: currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
applyTextBook(newBook, restoreLocation: restoreLocation)
}
}
private func updateReaderChrome() {
topToolView.apply(theme: configuration.theme)
topToolView.setTitle(title ?? parser?.metadata.title ?? epubURL.deletingPathExtension().lastPathComponent)
topToolView.setBookmarkEnabled(currentBookIdentifier != nil)
bottomToolView.apply(theme: configuration.theme)
bottomToolView.updateVisibility(
showsTableOfContents: configuration.showsTableOfContents,
allowsHighlights: configuration.allowsHighlights,
showsSettingsPanel: configuration.showsSettingsPanel
)
bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty)
bottomToolView.setAddHighlightEnabled(configuration.allowsHighlights && currentSelection != nil)
bottomToolView.setHighlightsEnabled(configuration.allowsHighlights && !activeHighlights.isEmpty)
updateBookmarkChrome()
}
private func presentHighlightsManager() {
guard configuration.allowsHighlights else { return }
guard !activeHighlights.isEmpty else { return }
let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: activeHighlights,
theme: configuration.theme,
sectionTitleProvider: { [weak self] highlight in
self?.titleForHighlight(highlight)
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
guard let self else { return }
highlightsController?.dismiss(animated: true) {
_ = self.go(to: highlight.location)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
_ = self?.updateHighlightNote(id: highlight.id, note: highlight.note)
}
highlightsController.onDeleteHighlight = { [weak self] highlight in
_ = self?.removeHighlight(id: highlight.id)
}
let navigationController = UINavigationController(rootViewController: highlightsController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
private func presentAnnotationCreation() {
guard configuration.allowsHighlights,
let currentSelection else {
return
}
presentAnnotationActionSheet(for: currentSelection)
}
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .highlight)
})
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .underline)
})
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = bottomToolView
popover.sourceRect = bottomToolView.bounds
}
present(alert, animated: true)
}
private func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
case .copy:
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .highlight)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
}
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
_ = addAnnotation(from: selection, style: style, note: note)
}
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
self?.createAnnotation(
from: selection,
style: .highlight,
note: alert?.textFields?.first?.text
)
})
present(alert, animated: true)
}
private func presentSettings() {
guard configuration.showsSettingsPanel else { return }
let settingsController = RDEPUBReaderSettingsViewController(
configuration: configuration,
brightness: currentBrightness
)
settingsController.onBrightnessChange = { [weak self] brightness in
self?.setScreenBrightness(brightness)
}
settingsController.onFontSizeChange = { [weak self] fontSize in
self?.updateConfiguration { $0.fontSize = fontSize }
}
settingsController.onLineHeightChange = { [weak self] lineHeightMultiple in
self?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak self] numberOfColumns in
self?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak self] displayType in
self?.updateConfiguration { $0.displayType = displayType }
}
settingsController.onThemeChange = { [weak self] theme in
self?.updateConfiguration { $0.theme = theme }
}
let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
guard let publication,
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return nil
}
return flattenedTableOfContents.first { item in
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
}?.title
}
private func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
var nextConfiguration = configuration
update(&nextConfiguration)
configuration = nextConfiguration
}
private func setScreenBrightness(_ brightness: CGFloat) {
currentBrightness = max(0, min(1, brightness))
UIScreen.main.brightness = currentBrightness
persistReaderSettingsIfNeeded()
}
private func persistReaderSettingsIfNeeded() {
let settings = RDEPUBReaderSettings.capture(
configuration: configuration,
brightness: currentBrightness
)
persistence?.saveReaderSettings(settings)
}
private func normalizedNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func requiresRepagination(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.fontSize != newConfiguration.fontSize ||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
oldConfiguration.columnGap != newConfiguration.columnGap ||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode ||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
}
private func requiresVisibleRefresh(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.theme != newConfiguration.theme
}
private func presentTableOfContents() {
guard configuration.showsTableOfContents else { return }
let items = flattenedTableOfContents
guard !items.isEmpty else { return }
let chapterController = RDEPUBReaderChapterListController(
items: items,
currentItem: currentTableOfContentsItem,
theme: configuration.theme
)
chapterController.onSelectItem = { [weak self] item in
guard let self else { return }
chapterController.dismiss(animated: true) {
_ = self.go(toTableOfContentsItem: item, animated: true)
}
}
let navigationController = UINavigationController(rootViewController: chapterController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
private func handleBackAction() {
if let navigationController, navigationController.viewControllers.first != self {
navigationController.popViewController(animated: true)
return
}
dismiss(animated: true)
}
private func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContents
guard !items.isEmpty else { return nil }
let currentPageNumber = max(readerView.currentPage + 1, 1)
let pageAnchoredMatch = items.last { item in
guard let pageNumber = item.pageNumber else { return false }
return pageNumber <= currentPageNumber
}
if let pageAnchoredMatch {
return pageAnchoredMatch
}
guard let publication,
let currentLocation = currentVisibleLocation(),
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
return nil
}
if let textBook,
let chapterData = textBook.chapterData(
for: currentLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
),
let tocItem = chapterData.primaryTableOfContentsItem(
from: publication.tableOfContents,
normalizer: { publication.resourceResolver.normalizedHref($0) }
) {
return items.last { $0.href == tocItem.href } ?? items.last {
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
return items.last { item in
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
private func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0
) -> [RDEPUBReaderTableOfContentsItem] {
items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber: Int?
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
pageNumber = chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
} else if let readingSession {
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
} else {
pageNumber = nil
}
let current = RDEPUBReaderTableOfContentsItem(
title: item.title,
href: item.href,
depth: depth,
pageNumber: pageNumber
)
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
}
}
private func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size
return RDEPUBNavigatorLayoutContext(
containerSize: containerSize,
pagesPerScreen: readerView.pagesPerScreen,
safeAreaInsets: view.safeAreaInsets,
userInterfaceIdiom: traitCollection.userInterfaceIdiom,
reflowableContentInsets: configuration.reflowableContentInsets
)
}
private func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
private func currentTextPageSize() -> CGSize {
let pageNum = readerView.currentPage >= 0 ? readerView.currentPage : nil
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize
}
return currentLayoutContext().viewportSize
}
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = UIFont.systemFont(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
}
private func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
)
}
private func resolvedTextRenderer() -> RDEPUBTextRenderer {
RDEPUBDTCoreTextRenderer()
}
private func resolvedPageDirection() -> RDReaderView.PageDirection {
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
}
private func ensurePaginationHostView() -> UIView {
let viewportSize = currentLayoutContext().viewportSize
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
if paginationHostView.superview == nil {
view.addSubview(paginationHostView)
view.sendSubviewToBack(paginationHostView)
}
paginationHostView.frame = hostFrame
return paginationHostView
}
private func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
guard let publication, activePages.indices.contains(pageIndex) else {
return nil
}
let page = activePages[pageIndex]
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
return currentPreferences().renderRequest(
for: page,
publication: publication,
viewportSize: currentLayoutContext().viewportSize,
targetLocation: pendingLocation,
highlights: highlights(for: page),
searchPresentation: searchPresentation(for: page)
)
}
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
guard let publication else { return [] }
if let spread = page.fixedSpread {
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
return activeHighlights.filter { highlight in
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return false
}
return hrefs.contains(normalizedHref)
}
}
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
let href = publication.spine[page.spineIndex].href
let normalizedHref = publication.resourceResolver.normalizedHref(href)
return activeHighlights.filter { highlight in
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
}
}
private func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
guard activePages.indices.contains(pageIndex) else { return nil }
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
}
private func handle(error: Error) {
isRepaginating = false
hideLoading()
activePages = []
activeChapters = []
textBook = nil
readerView.reloadData()
errorLabel.text = error.localizedDescription
errorLabel.isHidden = false
delegate?.epubReader(self, didFailWithError: error)
}
private func showLoading() {
errorLabel.isHidden = true
loadingIndicator.startAnimating()
}
private func hideLoading() {
loadingIndicator.stopAnimating()
}
private func currentViewportSignature() -> RDEPUBViewportSignature? {
let containerSize = readerView.bounds.size == .zero ? view.bounds.size : readerView.bounds.size
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
let safeAreaInsets = view.safeAreaInsets
return RDEPUBViewportSignature(
width: containerSize.width,
height: containerSize.height,
safeTop: safeAreaInsets.top,
safeLeft: safeAreaInsets.left,
safeBottom: safeAreaInsets.bottom,
safeRight: safeAreaInsets.right
)
}
private func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
guard didStartInitialLoad,
let signature = viewportSignature ?? currentViewportSignature() else {
return
}
if isRepaginating {
pendingViewportChangeReason = reason
return
}
if let lastAppliedViewportSignature,
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
return
}
lastAppliedViewportSignature = signature
if isExternalTextBook {
rebuildExternalTextBook()
return
}
guard publication != nil else { return }
repaginatePreservingCurrentLocation()
}
}
// MARK: - 书签管理
extension RDEPUBReaderController {
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let location = scopedBookmarkLocation(currentVisibleLocation()) else {
return nil
}
guard bookmark(matching: location) == nil else {
return nil
}
let newBookmark = RDEPUBBookmark(
bookIdentifier: currentBookIdentifier,
location: location,
chapterTitle: titleForBookmarkLocation(location),
note: normalizedBookmarkNote(note)
)
activeBookmarks.append(newBookmark)
persistBookmarksAndRefreshChrome()
return newBookmark
}
@discardableResult
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let location = scopedBookmarkLocation(currentVisibleLocation()) else {
return nil
}
if let existingBookmark = bookmark(matching: location) {
_ = removeBookmark(id: existingBookmark.id)
return nil
}
return addBookmark(note: note)
}
@discardableResult
public func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let index = activeBookmarks.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = activeBookmarks.remove(at: index)
persistBookmarksAndRefreshChrome()
return removed
}
@discardableResult
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let bookmark = bookmark(withID: id) else {
return false
}
return restoreReadingLocation(bookmark.location, animated: animated)
}
private func updateBookmarkChrome() {
topToolView.setBookmarkSelected(currentBookmark() != nil)
bottomToolView.setBookmarksEnabled(!activeBookmarks.isEmpty)
}
private func presentBookmarksManager() {
guard !activeBookmarks.isEmpty else { return }
let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: activeBookmarks,
theme: configuration.theme
)
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
guard let self else { return }
bookmarksController?.dismiss(animated: true) {
_ = self.restoreReadingLocation(bookmark.location, animated: true)
}
}
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
_ = self?.removeBookmark(id: bookmark.id)
}
let navigationController = UINavigationController(rootViewController: bookmarksController)
navigationController.modalPresentationStyle = .pageSheet
present(navigationController, animated: true)
}
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
if let currentLocation = currentVisibleLocation(),
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
return currentTableOfContentsItem?.title
}
return flattenedTableOfContents.last { item in
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
}?.title
}
private func persistBookmarksAndRefreshChrome() {
guard let currentBookIdentifier else { return }
persistence?.saveBookmarks(activeBookmarks, for: currentBookIdentifier)
delegate?.epubReader(self, didUpdateBookmarks: activeBookmarks)
updateBookmarkChrome()
}
private func currentBookmark() -> RDEPUBBookmark? {
guard let location = scopedBookmarkLocation(currentVisibleLocation()) else {
return nil
}
return bookmark(matching: location)
}
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
guard let location else { return nil }
return activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
}
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
return false
}
if let bookmarkAnchor = bookmark.location.rangeAnchor,
let locationAnchor = location.rangeAnchor {
return bookmarkAnchor == locationAnchor
}
if let bookmarkFragment = bookmark.location.fragment,
let locationFragment = location.fragment {
return bookmarkFragment == locationFragment
}
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
let threshold: Double = publication?.layout == .fixed ? 0.01 : 0.05
return progressionDelta <= threshold
}
private func bookmarkHref(for location: RDEPUBLocation) -> String {
publication?.resourceResolver.normalizedHref(location.href) ?? location.href
}
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
let rawHref = href.components(separatedBy: "#").first ?? href
return publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
}
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
guard let location else { return nil }
guard let publication else {
return RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
)
}
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
)
}
private func normalizedBookmarkNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
// MARK: - 搜索功能
/// 执行全文搜索,自动跳转到第一个匹配项
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
public func search(keyword: String) {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
clearSearch()
return
}
let matches = resolvedSearchMatches(for: normalizedKeyword)
searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
notifySearchStateChanged()
if matches.isEmpty {
refreshVisibleContentPreservingLocation()
} else {
_ = navigateToCurrentSearchMatch(animated: false)
}
}
@discardableResult
public func searchNext() -> Bool {
advanceSearch(by: 1)
}
@discardableResult
public func searchPrevious() -> Bool {
advanceSearch(by: -1)
}
public func clearSearch() {
searchState = nil
notifySearchStateChanged()
refreshVisibleContentPreservingLocation()
}
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
if let textBook, let publication {
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
}
if let parser, let publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
return []
}
private func advanceSearch(by delta: Int) -> Bool {
guard var searchState, !searchState.matches.isEmpty else {
return false
}
let currentIndex = searchState.currentMatchIndex ?? 0
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
searchState.currentMatchIndex = nextIndex
self.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
private func notifySearchStateChanged() {
delegate?.epubReader(self, didUpdateSearchResult: searchState?.result)
delegate?.epubReader(self, didChangeCurrentSearchMatch: searchState?.currentMatch)
}
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
guard let searchMatch = searchState?.currentMatch else {
refreshVisibleContentPreservingLocation()
return false
}
if let targetPageNumber = pageNumber(for: searchMatch),
readerView.currentPage == targetPageNumber - 1 {
refreshVisibleContentPreservingLocation()
return true
}
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
return restoreReadingLocation(location, animated: animated)
}
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
if let chapterData = textChapterData(forNormalizedHref: searchMatch.href) {
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
if let rangeLocation = searchMatch.rangeLocation,
let page = chapterData.page(containing: rangeLocation) {
return page.absolutePageIndex + 1
}
}
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
if let textBook, let publication {
return textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
}
private func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
guard let searchState, let publication else {
return nil
}
let pageHrefs: [String]
if let fixedSpread = page.fixedSpread {
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
} else if publication.spine.indices.contains(page.spineIndex) {
pageHrefs = [publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href) ?? publication.spine[page.spineIndex].href]
} else {
pageHrefs = []
}
guard !pageHrefs.isEmpty else {
return nil
}
let currentMatch = searchState.currentMatch
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
let resources = pageHrefs.map { href in
let matchCount = searchState.matches.filter {
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
}.count
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
return RDEPUBSearchPresentationResource(
href: href,
matchCount: matchCount,
activeLocalMatchIndex: activeLocalMatchIndex
)
}
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
}
}
// MARK: - RDReaderView 数据源与代理
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count
}
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
contentView.configure(
page: page,
pageNumber: pageNum + 1,
totalPages: textBook.pages.count,
configuration: configuration,
highlights: textHighlights(for: page),
searchState: searchState
)
return contentView
}
guard let publication,
let request = request(for: pageNum) else {
return containerView ?? UIView()
}
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
contentView.delegate = self
contentView.configure(
publication: publication,
request: request,
pageNumber: pageNum + 1,
totalPages: activePages.count,
theme: configuration.theme
)
return contentView
}
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
textBook == nil
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
if let textBook,
let chapterData = textBook.chapterData(for: page.href) {
return chapterData.highlights(on: page, from: activeHighlights)
}
guard let publication else {
return activeHighlights.filter { $0.location.href == page.href }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
private func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil }
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
return textBook.chapters.lazy
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.flatMap { textBook.chapterData(for: $0.href) }
}
public func topToolView(readerView: RDReaderView) -> UIView? {
topToolView
}
public func bottomToolView(readerView: RDReaderView) -> UIView? {
bottomToolView
}
public func pageNum(readerView: RDReaderView, pageNum: Int) {
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, pageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self)
}
if textBook != nil,
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
return
}
if let location = fallbackLocation(for: pageNum) {
persist(location: location)
}
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
readingSession?.transition(to: .idle)
}
}
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
}
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil,
!isRepaginating,
!isReconcilingTextPaginationSize,
pageNum >= 0,
let lastTextPaginationPageSize else {
return
}
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
guard resolvedSize.width > 0,
resolvedSize.height > 0 else {
return
}
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
guard sizeChanged else {
return
}
isReconcilingTextPaginationSize = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard self.textBook != nil, !self.isRepaginating else { return }
self.repaginatePreservingCurrentLocation()
}
}
}
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
return
}
let currentPage = activePages[readerView.currentPage]
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
return
}
persist(location: location)
readingSession?.updateReadingContext(
pageNumber: readerView.currentPage + 1,
location: location,
spineIndex: spineIndex,
chapterIndex: currentPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
} else {
updateCurrentSelection(nil)
}
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
relativeToSpineIndex: fromSpineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
print("EPUB JS Error: \(message)")
}
}
// MARK: - 文本内容视图代理(Native Text 渲染路径)
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
return
}
updateCurrentSelection(normalizedTextSelection(selection))
}
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
contentView.clearSelection()
}
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
guard let textBook,
let chapterData = textBook.chapterData(for: selection.location.href) else {
return scopedSelection(selection, relativeToSpineIndex: nil)
}
guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else {
return scopedSelection(selection, relativeToSpineIndex: nil)
}
let contentLength = max(chapterData.attributedContent.length, 1)
let lastInclusiveOffset = max(contentLength - 1, 1)
let start = max(0, min(payload.start, lastInclusiveOffset))
let endExclusive = max(start + 1, min(payload.end, contentLength))
let absoluteRange = NSRange(location: start, length: endExclusive - start)
let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier)
return RDEPUBSelection(
bookIdentifier: currentBookIdentifier,
location: location,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
private func pageNumber(for location: RDEPUBLocation) -> Int? {
if let textBook, let publication {
// Anchor-based lookup (character-level precision)
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
return page + 1
}
}
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
private func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
guard let textBook,
let publication,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
private func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
private func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> NativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
}
// MARK: - 手势识别器代理(用于 NavigationController 返回手势)
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}