1910 lines
73 KiB
Swift
1910 lines
73 KiB
Swift
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
|
||
}
|
||
}
|