import Foundation /// 搜索协调器,负责管理全文搜索的执行、结果导航和搜索状态通知。 final class RDEPUBReaderSearchCoordinator { private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { self.context = context } private var controller: RDEPUBReaderController? { context.controller } /// 按关键词执行全文搜索,匹配结果自动导航到首个命中位置 /// - Parameter keyword: 搜索关键词 func search(keyword: String) { guard let controller else { return } let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { clearSearch() return } let matches = resolvedSearchMatches(for: normalizedKeyword) controller.searchState = RDEPUBSearchState( keyword: normalizedKeyword, matches: matches, currentMatchIndex: matches.isEmpty ? nil : 0 ) notifySearchStateChanged() if matches.isEmpty { controller.refreshVisibleContentPreservingLocation() } else { _ = navigateToCurrentSearchMatch(animated: false) } } /// 跳转到下一个搜索匹配项 /// - Returns: 是否成功跳转 @discardableResult func searchNext() -> Bool { advanceSearch(by: 1) } /// 跳转到上一个搜索匹配项 /// - Returns: 是否成功跳转 @discardableResult func searchPrevious() -> Bool { advanceSearch(by: -1) } /// 跳转到指定索引的搜索匹配项 @discardableResult func selectSearchMatch(at index: Int) -> Bool { guard let controller else { return false } guard var searchState = controller.searchState, searchState.matches.indices.contains(index) else { return false } searchState.currentMatchIndex = index controller.searchState = searchState notifySearchStateChanged() return navigateToCurrentSearchMatch(animated: true) } /// 清除搜索状态并刷新当前可见内容 func clearSearch() { guard let controller else { return } controller.searchState = nil notifySearchStateChanged() controller.refreshVisibleContentPreservingLocation() } /// 构建指定页面的搜索结果展示信息,用于高亮渲染 /// - Parameter page: 目标页面 /// - Returns: 搜索展示数据,若无搜索状态则返回 nil func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? { guard let controller else { return nil } guard let searchState = controller.searchState, let publication = controller.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) } private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] { guard let controller else { return [] } if let textBook = controller.textBook { if let publication = controller.publication { return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword) } return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword) } if controller.readerContext.bookPageMap != nil, controller.publication != nil { return resolvedOnDemandSearchMatches(for: keyword) } if let parser = controller.parser, let publication = controller.publication { return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) } return [] } private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] { guard let controller, let publication = controller.publication else { return [] } let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { return [] } let buildableSpineIndices = publication.spine.indices.filter { index in let item = publication.spine[index] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } var matches: [RDEPUBSearchMatch] = [] for spineIndex in buildableSpineIndices { guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, store: controller.runtime.chapterRuntimeStore ) else { continue } let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0) let source = chapter.typesetAttributedString.string as NSString let fullLength = source.length guard fullLength > 0 else { continue } let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href var localMatchIndex = 0 var searchRange = NSRange(location: 0, length: fullLength) while searchRange.length > 0 { let foundRange = source.range(of: normalizedKeyword, 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: foundRange.length, 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 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, pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason), pages: runtimeChapter.pages ) return RDEPUBChapterData( chapter: textChapter, indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) ) } 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 advanceSearch(by delta: Int) -> Bool { guard let controller else { return false } guard var searchState = controller.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 controller.searchState = searchState notifySearchStateChanged() return navigateToCurrentSearchMatch(animated: true) } private func notifySearchStateChanged() { guard let controller else { return } controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result) controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch) } private func navigateToCurrentSearchMatch(animated: Bool) -> Bool { guard let controller else { return false } guard let searchMatch = controller.searchState?.currentMatch else { controller.refreshVisibleContentPreservingLocation() return false } if let targetPageNumber = pageNumber(for: searchMatch), controller.readerView.currentPage == targetPageNumber - 1 { controller.refreshVisibleContentPreservingLocation() return true } let location = RDEPUBLocation( bookIdentifier: controller.currentBookIdentifier, href: searchMatch.href, progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, rangeAnchor: searchMatch.rangeAnchor ) return controller.restoreReadingLocation(location, animated: animated) } private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? { guard let controller else { return nil } if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) { if let exactPageNumber = exactPageNumber( for: searchMatch, in: chapterData, keyword: controller.searchState?.keyword ) { return exactPageNumber } if let pageNumber = chapterData.pageNumber(for: searchMatch) { return pageNumber } if let rangeLocation = searchMatch.rangeLocation, let page = chapterData.page(containing: rangeLocation) { return page.absolutePageIndex + 1 } } let location = RDEPUBLocation( bookIdentifier: controller.currentBookIdentifier, href: searchMatch.href, progression: searchMatch.progression, lastProgression: searchMatch.progression, fragment: nil, rangeAnchor: searchMatch.rangeAnchor ) if let textBook = controller.textBook, let publication = controller.publication { return textBook.pageNumber( for: location, resolver: publication.resourceResolver, bookIdentifier: controller.currentBookIdentifier ) } return controller.readingSession?.pageIndex( for: location, bookIdentifier: controller.currentBookIdentifier ).map { $0 + 1 } } private func exactPageNumber( for searchMatch: RDEPUBSearchMatch, in chapterData: RDEPUBChapterData, keyword: String? ) -> Int? { let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !normalizedKeyword.isEmpty else { return nil } let source = chapterData.attributedContent.string as NSString let fullLength = source.length guard fullLength > 0 else { return nil } var localMatchIndex = 0 var searchRange = NSRange(location: 0, length: fullLength) while searchRange.length > 0 { let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange) guard foundRange.location != NSNotFound else { break } if localMatchIndex == searchMatch.localMatchIndex, let page = chapterData.page(containing: foundRange.location) { return page.absolutePageIndex + 1 } localMatchIndex += 1 let nextLocation = foundRange.location + max(foundRange.length, 1) if nextLocation >= fullLength { break } searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) } return nil } }