import UIKit extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate { public func numberOfPages(in readerView: RDEpubReaderView) -> Int { effectiveNumberOfPages } public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView { pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) } public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? { pageIdentifier(readerView: readerView, pageNum: index) } public func readerView( _ readerView: RDEpubReaderView, shouldPreloadPageAt index: Int ) -> Bool { // 试读墙由宿主提供唯一 UIView,不允许预加载创建另一容器并抢走它。 guard index != trialWallPageIndex else { return false } // 交互型固定版式页会在文档加载阶段触发页内音频/时间轴。 // 预加载会把相邻章节一并载入,导致当前章打开时多段音频同时播放。 // 这些页面只在真正展示时再创建,确保仅当前查看章节发声。 return request(for: index)?.isFixedLayout != true } public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? { topToolView(readerView: readerView) } public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? { bottomToolView(readerView: readerView) } public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int { readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count } public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView { // 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图 if let wallIndex = trialWallPageIndex, pageNum == wallIndex { return makeTrialWallPageView(reusableView: containerView) } if readerContext.bookPageMap != nil { _ = runtime.prepareOnDemandChapter( forAbsolutePageNumber: pageNum + 1, allowSynchronousLoad: false ) var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) let shouldAllowSynchronousFallback = ( readerView.currentPage < 0 || readerView.currentPage == pageNum ) && !readerView.isPageCurlTransitioning if resolvedPage == nil, shouldAllowSynchronousFallback { _ = runtime.prepareOnDemandChapter( forAbsolutePageNumber: pageNum + 1, allowSynchronousLoad: true ) resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) } if let resolvedPage { let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() contentView.delegate = self readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.configure( page: resolvedPage.page, pageNumber: pageNum + 1, totalPages: pageCountOfReaderView(readerView: readerView), configuration: configuration, contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum), chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap, chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets, highlights: textHighlights(for: resolvedPage.page), searchState: searchState(for: resolvedPage.page), displayCache: textDisplayCache ) return contentView } let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() contentView.delegate = self readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.configureLoading( pageNumber: pageNum + 1, totalPages: pageCountOfReaderView(readerView: readerView), configuration: configuration, contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum) ) return contentView } if let textBook, let page = textBook.page(at: pageNum + 1) { let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView() contentView.delegate = self readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView) contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in guard let readerView, let contentView else { return } readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed) } contentView.configure( page: page, pageNumber: pageNum + 1, totalPages: textBook.pages.count, configuration: configuration, contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum), chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap, chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:], highlights: textHighlights(for: page), searchState: searchState(for: page), displayCache: textDisplayCache ) 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: RDEpubReaderView, pageNum: Int) -> String? { if let wallIndex = trialWallPageIndex, pageNum == wallIndex { return NSStringFromClass(RDEPUBTrialWallContainerView.self) } return (textBook == nil && readerContext.bookPageMap == 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 searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? { guard let globalSearchState = searchState else { return nil } let matches: [RDEPUBSearchMatch] let currentMatchIndex: Int? if let chapterData = chapterData(for: page), let resolvedState = resolvedSearchState( for: page, chapterData: chapterData, globalSearchState: globalSearchState ) { matches = resolvedState.matches currentMatchIndex = resolvedState.currentMatchIndex } else { matches = globalSearchState.matches.filter { searchMatch in searchMatchBelongsToPage(searchMatch, page: page) } currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in matches.firstIndex(of: currentMatch) } } guard !matches.isEmpty || globalSearchState.currentMatch != nil else { return globalSearchState.matches.isEmpty ? globalSearchState : nil } return RDEPUBSearchState( keyword: globalSearchState.keyword, matches: matches, currentMatchIndex: currentMatchIndex ) } private func resolvedSearchState( for page: RDEPUBTextPage, chapterData: RDEPUBChapterData, globalSearchState: RDEPUBSearchState ) -> RDEPUBSearchState? { let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { return globalSearchState.matches.isEmpty ? globalSearchState : nil } let normalizedHref = normalizedPageHref(for: page) let exactMatches = exactChapterSearchMatches( in: chapterData, keyword: normalizedKeyword, normalizedHref: normalizedHref ) let pageMatches = exactMatches.filter { match in guard let rangeLocation = match.rangeLocation else { return false } let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1)) return NSIntersectionRange(range, page.contentRange).length > 0 } let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex }) } guard !pageMatches.isEmpty || currentMatchIndex != nil else { return globalSearchState.matches.isEmpty ? globalSearchState : nil } return RDEPUBSearchState( keyword: globalSearchState.keyword, matches: pageMatches, currentMatchIndex: currentMatchIndex ) } private func exactChapterSearchMatches( in chapterData: RDEPUBChapterData, keyword: String, normalizedHref: String ) -> [RDEPUBSearchMatch] { let source = chapterData.attributedContent.string as NSString let fullLength = source.length guard fullLength > 0 else { return [] } var matches: [RDEPUBSearchMatch] = [] var localMatchIndex = 0 var searchRange = NSRange(location: 0, length: fullLength) while searchRange.length > 0 { let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange) guard foundRange.location != NSNotFound else { break } let progressionDenominator = max(fullLength - 1, 1) let progression = Double(foundRange.location) / Double(progressionDenominator) matches.append( RDEPUBSearchMatch( href: normalizedHref, progression: progression, previewText: previewText(in: source, matchRange: foundRange), localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: max(foundRange.length, 1), rangeAnchor: chapterData.rangeAnchor(for: foundRange) ) ) localMatchIndex += 1 let nextLocation = foundRange.location + max(foundRange.length, 1) if nextLocation >= fullLength { break } searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) } return matches } private func previewText(in text: NSString, matchRange: NSRange) -> String { let previewRadius = 12 let start = max(matchRange.location - previewRadius, 0) let end = min(matchRange.location + matchRange.length + previewRadius, text.length) let range = NSRange(location: start, length: max(end - start, 0)) return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) } private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? { if let textBook, let chapterData = textBook.chapterData(for: page.href) { return chapterData } guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else { return nil } return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex) } private func makeChapterData( from runtimeChapter: RDEPUBRuntimeChapter, chapterIndex: Int ) -> RDEPUBChapterData { let textChapter = RDEPUBTextChapter( chapterIndex: chapterIndex, spineIndex: runtimeChapter.spineIndex, href: runtimeChapter.href, title: runtimeChapter.title, attributedContent: runtimeChapter.typesetAttributedString, fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets, cfiMap: runtimeChapter.chapterOffsetMap.cfiMap, pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), pages: runtimeChapter.pages ) return RDEPUBChapterData( chapter: textChapter, indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) ) } private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool { if let textBook, let chapterData = textBook.chapterData(for: page.href), let range = chapterData.absoluteRange(for: searchMatch) { return NSIntersectionRange(range, page.contentRange).length > 0 } let pageHref = normalizedPageHref(for: page) let matchHref = normalizedSearchHref(searchMatch.href) guard pageHref == matchHref else { return false } guard let rangeLocation = searchMatch.rangeLocation else { return false } let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength) return NSIntersectionRange(range, page.contentRange).length > 0 } private func normalizedPageHref(for page: RDEPUBTextPage) -> String { guard let publication else { return page.href } return publication.resourceResolver.normalizedHref(page.href) ?? page.href } private func normalizedSearchHref(_ href: String) -> String { guard let publication else { return href } return publication.resourceResolver.normalizedHref(href) ?? href } func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { readerContext.textChapterData(forNormalizedHref: href) } public func topToolView(readerView: RDEpubReaderView) -> UIView? { topToolView } public func bottomToolView(readerView: RDEpubReaderView) -> UIView? { bottomToolView } public func pageNum(readerView: RDEpubReaderView, pageNum: Int) { RDEPUBMemoryProbe.logPageTurn() readerContext.markUserNavigationActivity() updateCurrentSelection(nil) reconcileTextPaginationSizeIfNeeded(for: pageNum) guard !isRepaginating else { return } let previousCurrentPage = readerView.currentPage let wasPageCurlTransitioning = readerView.isPageCurlTransitioning runtime.applyPendingFullPageMapIfNeeded() if wasPageCurlTransitioning { DispatchQueue.main.async { [weak self, weak readerView] in guard let self, let readerView, !readerView.isPageCurlTransitioning else { return } self.runtime.applyPendingFullPageMapIfNeeded() } } let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum { return } if readerContext.bookPageMap != nil { _ = runtime.prepareOnDemandChapter( forAbsolutePageNumber: effectivePageNum + 1, allowSynchronousLoad: false ) runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1) } // 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI) if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex { delegate?.epubReaderDidReachTrialWall(self) return } let totalPages = pageCountOfReaderView(readerView: readerView) if totalPages > 0, effectivePageNum == totalPages - 1 { delegate?.epubReaderDidReachEnd(self) } if (textBook != nil || readerContext.bookPageMap != nil), let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) { persist(location: location) synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location) runtime.locationCoordinator.recordPageChangeIfNeeded() return } if let location = fallbackLocation(for: effectivePageNum) { persist(location: location) } runtime.locationCoordinator.recordPageChangeIfNeeded() } public func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool) { _ = isLandscape runtime.viewportMonitor.capturePendingPresentationRestoreLocation() } private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) { guard textBook != nil || readerContext.bookPageMap != nil, !isRepaginating, !isReconcilingTextPaginationSize, pageNum >= 0, let lastTextPaginationPageSize else { return } let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) guard resolvedSize.width > 0, resolvedSize.height > 0 else { return } let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5 || abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5 guard sizeChanged else { return } isReconcilingTextPaginationSize = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self else { return } self.isReconcilingTextPaginationSize = false guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return } let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage) guard currentSize.width > 0, currentSize.height > 0 else { return } guard let previousPageSize = self.lastTextPaginationPageSize else { return } let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5 || abs(currentSize.height - previousPageSize.height) > 0.5 guard stillChanged else { return } self.repaginatePreservingCurrentLocation() } } }