300 lines
12 KiB
Swift
300 lines
12 KiB
Swift
// RDEPUBReaderController+ContentDelegates.swift
|
||
// EPUB 阅读器内容视图代理实现
|
||
// 处理 Web 渲染路径(RDEPUBWebContentViewDelegate)和 Native Text 渲染路径
|
||
// (RDEPUBTextContentViewDelegate)的位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||
|
||
import UIKit
|
||
|
||
/// RDEPUBReaderController 内容代理扩展
|
||
///
|
||
/// 本文件实现 EPUB 阅读器的内容视图代理,处理 Web 渲染路径和 Native Text 渲染路径的
|
||
/// 位置更新、选区变化、链接跳转、错误日志等事件回调。
|
||
|
||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||
|
||
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||
/// Web 内容视图位置更新回调,同步阅读上下文与持久化位置
|
||
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
|
||
)
|
||
}
|
||
|
||
/// Web 内容视图选区变化回调,转换为统一选区模型
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||
if let selection {
|
||
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
|
||
} else {
|
||
updateCurrentSelection(nil)
|
||
}
|
||
}
|
||
|
||
/// Web 内容视图选区菜单操作回调
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||
handleSelectionMenuAction(action, selection: currentSelection)
|
||
}
|
||
|
||
/// Web 内容视图内部链接点击回调,执行页内跳转
|
||
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)
|
||
}
|
||
|
||
/// Web 内容视图外部链接点击回调,使用系统浏览器打开
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||
}
|
||
|
||
/// Web 内容视图 JavaScript 错误日志回调
|
||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||
print("EPUB JS Error: \(message)")
|
||
}
|
||
}
|
||
|
||
// MARK: - 文本内容视图代理(Native Text 渲染路径)
|
||
|
||
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||
/// 文本内容视图选区变化回调,标准化后更新当前选区
|
||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
|
||
guard let selection else {
|
||
updateCurrentSelection(nil)
|
||
return
|
||
}
|
||
updateCurrentSelection(selection)
|
||
}
|
||
|
||
/// 文本内容视图选区菜单操作回调
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||
selection: RDEPUBSelection?
|
||
) {
|
||
handleSelectionMenuAction(action, selection: selection ?? currentSelection)
|
||
contentView.clearSelection()
|
||
}
|
||
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||
sourceRect: CGRect
|
||
) {
|
||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||
}
|
||
|
||
|
||
/// 根据 EPUB 位置计算对应的页码
|
||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||
if let publication,
|
||
let bookPageMap = readerContext.bookPageMap,
|
||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
let localPageIndex: Int
|
||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||
localPageIndex = summary.pageRanges.firstIndex {
|
||
let range = $0.nsRange
|
||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||
} else {
|
||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||
}
|
||
return bookPageMap.absolutePageIndex(
|
||
spineIndex: spineIndex,
|
||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||
).map { $0 + 1 }
|
||
}
|
||
|
||
if let textBook, let publication {
|
||
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
|
||
)
|
||
}
|
||
|
||
/// 根据页码解析对应的文本位置
|
||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
|
||
let startOffset = resolvedPage.page.pageStartOffset
|
||
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
||
let fragmentID = nearestFragmentID(
|
||
beforeOrAt: startOffset,
|
||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
|
||
)
|
||
let location = RDEPUBLocation(
|
||
bookIdentifier: currentBookIdentifier,
|
||
href: resolvedPage.page.href,
|
||
progression: Double(startOffset) / Double(chapterLength),
|
||
lastProgression: Double(endOffset) / Double(chapterLength),
|
||
fragment: fragmentID,
|
||
rangeAnchor: RDEPUBTextRangeAnchor(
|
||
start: RDEPUBTextAnchor(
|
||
fileIndex: resolvedPage.page.spineIndex,
|
||
row: 0,
|
||
column: 0,
|
||
chapterOffset: startOffset,
|
||
fragmentID: fragmentID
|
||
),
|
||
end: RDEPUBTextAnchor(
|
||
fileIndex: resolvedPage.page.spineIndex,
|
||
row: 0,
|
||
column: 0,
|
||
chapterOffset: endOffset,
|
||
fragmentID: fragmentID
|
||
)
|
||
)
|
||
)
|
||
if let publication {
|
||
return publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
}
|
||
return location
|
||
}
|
||
|
||
guard let textBook,
|
||
let page = textBook.page(at: pageNumber) else {
|
||
return nil
|
||
}
|
||
|
||
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
|
||
guard let location else { return nil }
|
||
|
||
if let publication {
|
||
return publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
}
|
||
return location
|
||
}
|
||
|
||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||
readingSession?.updateReadingContext(
|
||
pageNumber: pageNumber,
|
||
location: location,
|
||
spineIndex: resolvedPage.page.spineIndex,
|
||
chapterIndex: resolvedPage.chapterIndex,
|
||
bookIdentifier: currentBookIdentifier
|
||
)
|
||
return
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
|
||
/// 从文本书籍生成原生文本快照(页面列表 + 章节信息)
|
||
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
|
||
let chapters = textBook.chapterInfos
|
||
let pages = textBook.pages.map {
|
||
EPUBPage(
|
||
spineIndex: $0.spineIndex,
|
||
chapterIndex: $0.chapterIndex,
|
||
pageIndexInChapter: $0.pageIndexInChapter,
|
||
totalPagesInChapter: $0.totalPagesInChapter,
|
||
chapterTitle: $0.chapterTitle,
|
||
fixedSpread: nil
|
||
)
|
||
}
|
||
return (pages, chapters)
|
||
}
|
||
|
||
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
|
||
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
|
||
}
|
||
|
||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||
if let anchor = location.rangeAnchor?.start {
|
||
return anchor.chapterOffset
|
||
}
|
||
if let fragment = location.fragment,
|
||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||
return offset
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
|
||
guard pageCount > 1 else { return 0 }
|
||
return min(
|
||
pageCount - 1,
|
||
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
|
||
)
|
||
}
|
||
|
||
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
|
||
var bestID: String?
|
||
var bestOffset = Int.min
|
||
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||
bestOffset = fragmentOffset
|
||
bestID = fragmentID
|
||
}
|
||
return bestID
|
||
}
|
||
}
|