ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift

1910 lines
73 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
public final class RDEPUBReaderController: UIViewController {
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])
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()
}
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
public private(set) var currentSelection: RDEPUBSelection?
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
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 paginationToken = UUID()
private var paginator: RDEPUBPaginator?
private var searchState: RDEPUBSearchState?
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
private var isWaitingForViewportTransitionCompletion = false
/// TextBook EPUB
private var isExternalTextBook = false
/// TextBook URL TextBook
private var textFileURL: URL?
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
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 }
isWaitingForViewportTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self else { return }
self.isWaitingForViewportTransitionCompletion = false
self.view.layoutIfNeeded()
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
}
}
public func reloadBook() {
didStartInitialLoad = false
parser = nil
publication = nil
readingSession = nil
textBook = nil
activePages = []
activeChapters = []
activeBookmarks = []
activeHighlights = []
searchState = nil
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
isWaitingForViewportTransitionCompletion = false
updateCurrentSelection(nil)
readerView.reloadData()
startInitialLoadIfNeeded()
}
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
restoreReadingLocation(location)
}
@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: " · ")
}
@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 displayTypeDidChange = readerView.currentDisplayType != configuration.displayType
let preservedLocation = displayTypeDidChange ? currentVisibleLocation() : nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.pageDirection = resolvedPageDirection()
updateReaderChrome()
if displayTypeDidChange {
readerView.switchReaderDisplayType(configuration.displayType)
if let preservedLocation {
_ = restoreReadingLocation(preservedLocation)
}
}
}
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 builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
let pageSize = currentTextPageSize()
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 = currentVisibleLocation() ?? persistenceLocation()
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()
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.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.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
}
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 {
pageNumber = 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 viewportSize = currentLayoutContext().viewportSize
let insets = configuration.reflowableContentInsets
return CGSize(
width: max(viewportSize.width - insets.left - insets.right, 1),
height: max(viewportSize.height - insets.top - insets.bottom, 1)
)
}
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 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()
}
}
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
}
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) {
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: chapterData.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
if let pageNumber = chapterData.pageNumber(for: location) {
return pageNumber
}
if let rangeLocation = searchMatch.rangeLocation,
let page = chapterData.pages.first(where: {
rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset
}) {
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)
}
}
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)
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
}
}
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)")
}
}
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(for: page.href)?.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.chapters.map {
EPUBChapterInfo(
spineIndex: $0.spineIndex,
title: $0.title,
pageCount: $0.pages.count
)
}
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)
}
}
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}