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
@@ -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
)
}