132 lines
5.9 KiB
Swift
132 lines
5.9 KiB
Swift
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
|
||
}
|
||
}
|