import Foundation final class RDEPUBTextSearchEngine: RDEPUBSearchEngine { private let textBook: RDEPUBTextBook private let publication: RDEPUBPublication init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) { self.textBook = textBook self.publication = publication } 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) let rangeAnchor = chapterData.rangeAnchor(for: foundRange) matches.append( RDEPUBSearchMatch( href: normalizedHref, progression: progression, previewText: previewText(in: source, matchRange: foundRange), localMatchIndex: localMatchIndex, rangeLocation: foundRange.location, rangeLength: foundRange.length, rangeAnchor: rangeAnchor, cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue, rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue ) ) 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 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) } 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 chapterData = RDEPUBChapterData( chapter: chapter, indexTable: RDEPUBTextIndexTable(chapters: [chapter]) ) 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 rangeAnchor = chapterData.rangeAnchor(for: foundRange) let cfi = chapterData.indexTable.cfi(for: rangeAnchor.start) let cfiRange = chapterData.indexTable.cfiRange(for: rangeAnchor) 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: rangeAnchor, cfi: cfi?.rawValue ?? cfiRange?.start.rawValue, rangeCFI: cfiRange?.rawValue ) ) localMatchIndex += 1 let nextLocation = foundRange.location + max(foundRange.length, 1) if nextLocation >= fullLength { break } searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation) } } return matches } }