Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift
T
shenandshen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

353 lines
14 KiB
Swift

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