117 lines
4.5 KiB
Swift
117 lines
4.5 KiB
Swift
import Foundation
|
|
import UIKit
|
|
|
|
protocol RDEPUBSearchEngine {
|
|
func search(keyword: String) -> [RDEPUBSearchMatch]
|
|
}
|
|
|
|
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
|
private let parser: RDEPUBParser
|
|
private let publication: RDEPUBPublication
|
|
|
|
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
|
self.parser = parser
|
|
self.publication = publication
|
|
}
|
|
|
|
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalizedKeyword.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
var searchMatches: [RDEPUBSearchMatch] = []
|
|
for item in publication.spine where item.linear {
|
|
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
|
let html = parser.htmlString(forRelativePath: item.href) else {
|
|
continue
|
|
}
|
|
|
|
let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent())
|
|
let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href
|
|
searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword))
|
|
}
|
|
|
|
return searchMatches
|
|
}
|
|
|
|
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
|
guard let data = html.data(using: .utf8) else {
|
|
return fallbackPlainText(fromHTML: html)
|
|
}
|
|
|
|
var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
|
.documentType: NSAttributedString.DocumentType.html,
|
|
.characterEncoding: String.Encoding.utf8.rawValue
|
|
]
|
|
if let baseURL {
|
|
options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL
|
|
}
|
|
|
|
if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
|
return attributed.string
|
|
}
|
|
return fallbackPlainText(fromHTML: html)
|
|
}
|
|
|
|
private func fallbackPlainText(fromHTML html: String) -> String {
|
|
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
|
return stripped
|
|
.replacingOccurrences(of: " ", with: " ")
|
|
.replacingOccurrences(of: "&", with: "&")
|
|
.replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">")
|
|
.replacingOccurrences(of: "'", with: "'")
|
|
.replacingOccurrences(of: """, with: "\"")
|
|
}
|
|
|
|
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
|
let source = text as NSString
|
|
let fullLength = source.length
|
|
guard fullLength > 0 else {
|
|
return []
|
|
}
|
|
|
|
var results: [RDEPUBSearchMatch] = []
|
|
var localMatchIndex = 0
|
|
var searchRange = NSRange(location: 0, length: fullLength)
|
|
|
|
while searchRange.length > 0 {
|
|
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
|
guard foundRange.location != NSNotFound else {
|
|
break
|
|
}
|
|
|
|
let progressionDenominator = max(fullLength - 1, 1)
|
|
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
|
results.append(
|
|
RDEPUBSearchMatch(
|
|
href: href,
|
|
progression: progression,
|
|
previewText: previewText(in: source, matchRange: foundRange),
|
|
localMatchIndex: localMatchIndex,
|
|
rangeLocation: foundRange.location,
|
|
rangeLength: foundRange.length
|
|
)
|
|
)
|
|
|
|
localMatchIndex += 1
|
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
|
if nextLocation >= fullLength {
|
|
break
|
|
}
|
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|