feat: add in-reader search and restructure documentation

- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-05 17:34:50 +08:00
co-authored by Claude Opus 4.7
parent d20196ee34
commit 0e7c0577e3
37 changed files with 4941 additions and 7621 deletions
@@ -19,12 +19,15 @@ final class RDEPUBReaderChromeCoordinator {
context.controller
}
///
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
self?.handleBackAction()
}
toolView.onSearch = { [weak self] in
self?.toggleSearchBar()
}
toolView.onToggleBookmark = { [weak self] in
_ = self?.context.runtime?.toggleBookmark()
}
@@ -76,6 +79,7 @@ final class RDEPUBReaderChromeCoordinator {
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
)
controller.updateBookmarkChrome()
updateSearchBar()
}
///
@@ -140,6 +144,29 @@ final class RDEPUBReaderChromeCoordinator {
controller.present(navigationController, animated: true)
}
/// /
func toggleSearchBar() {
guard let controller else { return }
if controller.isSearchBarVisible {
controller.hideSearchBar()
} else {
controller.showSearchBar()
}
}
///
func updateSearchBar() {
guard let controller else { return }
controller.searchBarView.apply(theme: controller.configuration.theme)
if let searchState = controller.searchState {
if let index = searchState.currentMatchIndex {
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
controller.searchBarView.showNoResults()
}
}
}
/// pop dismiss
func handleBackAction() {
guard let controller else { return }
@@ -50,6 +50,9 @@ final class RDEPUBReaderContext {
var paginator: RDEPUBPaginator?
///
var searchState: RDEPUBSearchState?
/// BookPageMap
/// map
var pendingFullPageMap: RDEPUBBookPageMap?
///
var lastTextPaginationPageSize: CGSize?
/// OperationQueue
@@ -205,18 +208,6 @@ final class RDEPUBReaderContext {
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash: String
if let parser,
let publication,
@@ -226,15 +217,53 @@ final class RDEPUBReaderContext {
} else {
contentHash = ""
}
return chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
return RDEPUBChapterCacheKey(
/// 使 contentHash HTML SHA-256
///
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedContentHash,
renderSignature: currentRenderSignature()
)
}
/// 使 contentHash
/// live context
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
renderSignature: String
) -> RDEPUBChapterCacheKey {
RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
chapterContentHash: precomputedContentHash
)
}
///
func currentRenderSignature() -> String {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return [
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
}
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
}
@@ -10,6 +10,8 @@ import Foundation
/// -
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
@@ -80,6 +82,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard let controller = context.controller else { return }
context.textBook = textBook
context.bookPageMap = nil
context.pendingFullPageMap = nil
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
@@ -99,6 +102,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
@@ -388,6 +392,7 @@ final class RDEPUBReaderPaginationCoordinator {
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let style = context.currentTextRenderStyle()
let renderSignature = context.currentRenderSignature()
let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
@@ -397,10 +402,30 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
guard context.controller != nil else { return }
// contentHash + SHA-256
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
contentHashBySpineIndex[spineIndex] = ""
continue
}
contentHashBySpineIndex[spineIndex] = html.sha256Hex
}
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
let catalog = allBuildableIndices.map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
title: item.title
@@ -438,6 +463,7 @@ final class RDEPUBReaderPaginationCoordinator {
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
@@ -447,6 +473,8 @@ final class RDEPUBReaderPaginationCoordinator {
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
queue.addOperation {
guard context.controller != nil,
@@ -471,7 +499,12 @@ final class RDEPUBReaderPaginationCoordinator {
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
@@ -500,17 +533,24 @@ final class RDEPUBReaderPaginationCoordinator {
guard let renderResult else { return }
var partialMap: RDEPUBBookPageMap?
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
lastAppliedCount = totalResolvedCount
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
snapshot = summariesBySpineIndex
}
resultLock.unlock()
if let partialMap {
if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
timingLock.lock()
totalMergeMs += mergeElapsed
timingLock.unlock()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
@@ -532,6 +572,7 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
@@ -539,7 +580,8 @@ final class RDEPUBReaderPaginationCoordinator {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
)
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = workerCount
@@ -550,10 +592,12 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
DispatchQueue.main.async {
@@ -565,15 +609,23 @@ final class RDEPUBReaderPaginationCoordinator {
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
let parser = context.parser else {
return nil
}
let renderSignature = context.currentRenderSignature()
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
let item = publication.spine[spineIndex]
let href = item.href
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
key: context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
href: href,
title: item.title
)
}
@@ -52,6 +52,7 @@ final class RDEPUBReaderRuntime {
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
@@ -312,31 +313,37 @@ final class RDEPUBReaderRuntime {
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
guard let readerView = context.readerView,
// map
// map
context.pendingFullPageMap = bookPageMap
}
/// BookPageMap
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
let currentPage = max(readerView.currentPage, 0)
context.pendingFullPageMap = nil
// map
let currentLocation = locationCoordinator.currentVisibleLocation()
// map
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
context.bookPageMap = pendingMap
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap))
//
readerView.reloadPageCountOnly()
//
let cv = readerView.collectionView
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
if currentPage > maxValidPage {
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
}
}
// map
if let currentLocation {
locationCoordinator.persist(location: currentLocation)
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
locationCoordinator.persist(location: resolvedLocation)
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
}
} else {
readerView.reloadPageCountOnly()
}
}
@@ -530,6 +537,7 @@ final class RDEPUBReaderRuntime {
func clearOnDemandPageModeState() {
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
context.pendingFullPageMap = nil
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {