// 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 } }