feat: chapter runtime refactoring and related updates

- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-26 18:50:07 +09:00
co-authored by Claude
parent b8aa10c535
commit 22e7e44220
123 changed files with 3701 additions and 9118 deletions
@@ -4,6 +4,24 @@ final class RDEPUBReaderSearchCoordinator {
private unowned let context: RDEPUBReaderContext
private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
private let tokenLock = NSLock()
private var _currentSearchToken: UUID = UUID()
private var currentSearchToken: UUID {
get {
tokenLock.lock()
defer { tokenLock.unlock() }
return _currentSearchToken
}
set {
tokenLock.lock()
_currentSearchToken = newValue
tokenLock.unlock()
}
}
init(context: RDEPUBReaderContext) {
self.context = context
}
@@ -20,18 +38,31 @@ final class RDEPUBReaderSearchCoordinator {
return
}
let matches = resolvedSearchMatches(for: normalizedKeyword)
controller.searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
notifySearchStateChanged()
let token = UUID()
currentSearchToken = token
if matches.isEmpty {
controller.refreshVisibleContentPreservingLocation()
} else {
_ = navigateToCurrentSearchMatch(animated: false)
let searchEngine = makeSearchEngine()
let layoutSnapshot = context.makeLayoutSnapshot()
searchQueue.async { [weak self] in
guard let self, self.currentSearchToken == token else { return }
let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
DispatchQueue.main.async { [weak self] in
guard let self, self.currentSearchToken == token else { return }
guard let controller = self.context.controller else { return }
controller.searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
self.notifySearchStateChanged()
if matches.isEmpty {
controller.refreshVisibleContentPreservingLocation()
} else {
_ = self.navigateToCurrentSearchMatch(animated: false)
}
}
}
}
@@ -52,7 +83,6 @@ final class RDEPUBReaderSearchCoordinator {
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
@@ -60,6 +90,7 @@ final class RDEPUBReaderSearchCoordinator {
}
func clearSearch() {
currentSearchToken = UUID()
guard let controller else { return }
controller.searchState = nil
notifySearchStateChanged()
@@ -105,29 +136,43 @@ final class RDEPUBReaderSearchCoordinator {
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
}
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller else { return [] }
if let textBook = controller.textBook {
if let publication = controller.publication {
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
}
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
// MARK: - Private
private func makeSearchEngine() -> SearchEngineSnapshot? {
guard let controller else { return nil }
if let textBook = controller.textBook, let publication = controller.publication {
return .textBook(textBook, publication)
}
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
return resolvedOnDemandSearchMatches(for: keyword)
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
return .onDemand(publication)
}
if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
return .html(parser, publication)
}
return []
return nil
}
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller,
let publication = controller.publication else {
return []
private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
guard let engine else { return [] }
switch engine {
case .textBook(let textBook, let publication):
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
case .onDemand(let publication):
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
case .html(let parser, let publication):
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
}
private enum SearchEngineSnapshot {
case textBook(RDEPUBTextBook, RDEPUBPublication)
case onDemand(RDEPUBPublication)
case html(RDEPUBParser, RDEPUBPublication)
}
// H-09: Each chapter iteration is wrapped in autoreleasepool to release
// the chapter's typesetAttributedString memory between iterations.
private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return []
@@ -140,52 +185,58 @@ final class RDEPUBReaderSearchCoordinator {
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: controller.runtime.chapterRuntimeStore
) else {
continue
}
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.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 chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
layoutSnapshot: layoutSnapshot
) else {
return []
}
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
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return [] }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
var localMatches: [RDEPUBSearchMatch] = []
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)
localMatches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: max(foundRange.length, 1),
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
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return localMatches
} // end autoreleasepool
matches.append(contentsOf: chapterMatches)
}
return matches
@@ -236,8 +287,9 @@ final class RDEPUBReaderSearchCoordinator {
private func notifySearchStateChanged() {
guard let controller else { return }
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
let state = controller.searchState
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
}
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
@@ -346,4 +398,4 @@ final class RDEPUBReaderSearchCoordinator {
return nil
}
}
}