ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift
shenlei 0e7c0577e3 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>
2026-06-05 17:34:50 +08:00

660 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
/// EPUB
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
let publication = context.publication,
let readingSession = context.readingSession else {
return
}
controller.isRepaginating = true
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
context.paginationToken = token
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable {
print("[EPUB][Pagination] path=text-reflowable-on-demand")
paginateTextPublication(
parser: parser,
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation,
token: token
)
return
}
if publication.layout == .fixed {
print("[EPUB][Pagination] path=fixed-layout")
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
return
}
let paginator = context.makePaginator()
print("[EPUB][Pagination] path=web-paginator")
context.paginator = paginator
paginator.calculate(
parser: parser,
hostingView: controller.ensurePaginationHostView(),
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
) { [weak controller] pageCounts in
guard let controller, self.context.paginationToken == token else { return }
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: pageCounts,
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
self.context.paginator = nil
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
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)
guard !textBook.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
/// Fixed Layout Web
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.pendingFullPageMap = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
context.readingSession?.transition(to: .idle)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
}
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
/// 使
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
?? context.currentVisibleLocation()
?? context.persistenceLocation()
paginatePublication(restoreLocation: restoreLocation)
}
///
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
readerView.reloadData()
if let restoreLocation {
_ = context.restoreReadingLocation(restoreLocation)
}
}
///
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
let pageSize = controller.currentTextPageSize()
let style = controller.currentTextRenderStyle()
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
}
}
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?,
token: UUID
) {
guard let controller = context.controller else { return }
let context = self.context
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
guard prioritizedCandidates.first != nil else {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: RDEPUBParserError.emptySpine)
}
return
}
do {
guard context.controller != nil,
let runtime = context.runtime else { return }
RDEPUBBackgroundTrace.log(
"QuickOpen",
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
)
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadFirstRenderableRuntimeChapter"
) {
try self.loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: prioritizedCandidates,
runtime: runtime
)
}
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
runtime: runtime
)
}
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
runtime.chapterRuntimeStore.setCurrentChapter(
spineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: error)
}
}
}
}
private func loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: [Int],
runtime: RDEPUBReaderRuntime
) throws -> RDEPUBRuntimeChapter {
var lastError: Error?
for spineIndex in prioritizedSpineIndices {
do {
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
} catch {
lastError = error
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
}
}
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication,
maxChapterCount: context.configuration.onDemandChapterWindowSize
)
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
}
return chapters
}
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < normalizedMaxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == normalizedMaxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
while context.controller != nil,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
let publication = context.publication else { return }
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)
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
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,
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
let restored = summaryDiskCache?.readAll(keys: catalog)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
)
let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock()
var summariesBySpineIndex = cachedSummaries
var totalResolvedCount = cachedSpineIndices.count
var lastAppliedCount = cachedSpineIndices.count
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
)
let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
self.waitForReadingInteractionToSettle(using: context)
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
var totalWriteMs: Double = 0
var totalMergeMs: Double = 0
var completedChapters = 0
var failedChapters = 0
let timingLock = NSLock()
let queue = OperationQueue()
queue.name = "com.rdreader.metadata.parse"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
queue.addOperation {
guard context.controller != nil,
context.paginationToken == token else {
return
}
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return nil
}
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
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,
fragmentOffsets: chapter.fragmentOffsets,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
let writeStart = CFAbsoluteTimeGetCurrent()
summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
timingLock.lock()
totalRenderMs += renderElapsed
totalWriteMs += writeElapsed
completedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
)
return summary
}
guard let renderResult else { return }
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
lastAppliedCount = totalResolvedCount
snapshot = summariesBySpineIndex
}
resultLock.unlock()
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 }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
}
}
}
queue.waitUntilAllOperationsAreFinished()
summaryDiskCache?.flushPendingWrites()
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock()
let renderTotal = Int(totalRenderMs)
let writeTotal = Int(totalWriteMs)
let mergeTotal = Int(totalMergeMs)
let rendered = completedChapters
let failed = failedChapters
timingLock.unlock()
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
RDEPUBBackgroundTrace.log(
"MetadataParse",
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
)
context.lastMetadataParseWallClockMs = wallClockMs
context.lastMetadataParseConcurrency = workerCount
guard context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
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) finalMergeMs=\(finalMergeMs)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
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,
precomputedContentHash: contentHash,
renderSignature: renderSignature
),
spineIndex: spineIndex,
href: href,
title: item.title
)
}
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
private func buildPageMap(
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
summaries: [Int: RDEPUBChapterSummary]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
guard let summary = summaries[item.spineIndex] else { continue }
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
return builder.build()
}
}