Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift
T

132 lines
5.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}