import Foundation /// EPUB 文本搜索引擎:在分页书籍中执行全文搜索。 /// /// 实现 `RDEPUBSearchEngine` 协议,遍历所有章节的富文本内容, /// 使用大小写不敏感匹配查找关键词,返回搜索结果列表。 /// 每个搜索结果包含进度位置、预览文本和语义锚点。 final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { private let textBook: RDEPUBTextBook private let publication: RDEPUBPublication init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) { self.textBook = textBook self.publication = publication } /// 在全书中搜索关键词,返回所有匹配结果。 /// /// 搜索流程: /// 1. 遍历所有章节 /// 2. 在每章的富文本字符串中执行大小写不敏感搜索 /// 3. 计算匹配位置的阅读进度(progression) /// 4. 生成预览文本(匹配位置前后各 12 个字符) /// 5. 生成语义锚点(用于跨设备/跨字体精确定位) func search(keyword: String) -> [RDEPUBSearchMatch] { let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { return [] } var matches: [RDEPUBSearchMatch] = [] for chapter in textBook.chapters { guard let chapterData = textBook.chapterData(for: chapter.href) else { continue } let source = chapter.attributedContent.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 } /// 生成搜索结果的预览文本:匹配位置前后各取 12 个字符 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) } /// 无 publication 时的搜索(用于 txt 等外部文本文件) static func searchWithoutPublication(textBook: RDEPUBTextBook, keyword: String) -> [RDEPUBSearchMatch] { let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKeyword.isEmpty else { return [] } var matches: [RDEPUBSearchMatch] = [] for chapter in textBook.chapters { let source = chapter.attributedContent.string as NSString let fullLength = source.length guard fullLength > 0 else { continue } 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) let previewRadius = 12 let previewStart = max(foundRange.location - previewRadius, 0) let previewEnd = min(foundRange.location + foundRange.length + previewRadius, fullLength) let previewRange = NSRange(location: previewStart, length: max(previewEnd - previewStart, 0)) let previewText = source.substring(with: previewRange).trimmingCharacters(in: .whitespacesAndNewlines) matches.append( RDEPUBSearchMatch( href: chapter.href, progression: progression, previewText: previewText, localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: foundRange.length, rangeAnchor: nil ) ) localMatchIndex += 1 let nextLocation = foundRange.location + max(foundRange.length, 1) if nextLocation >= fullLength { break } searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) } } return matches } }