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) } /// 清除搜索状态并刷新当前可见内容 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 let parser = controller.parser, let publication = controller.publication { return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword) } return [] } 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 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 } } }