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
@@ -111,6 +111,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
// map
runtime.applyPendingFullPageMapIfNeeded()
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
@@ -155,6 +155,9 @@ public final class RDEPUBReaderController: UIViewController {
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
///
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
@@ -273,6 +276,9 @@ public final class RDEPUBReaderController: UIViewController {
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
@@ -302,4 +308,100 @@ public final class RDEPUBReaderController: UIViewController {
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: -
/// readerView
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
searchBarView.heightAnchor.constraint(equalToConstant: 52)
])
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
UIView.animate(withDuration: 0.3) {
self.searchBarView.transform = .identity
}
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
}
//
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
///
/// - Parameter clearSearch:
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.3, animations: {
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
///
private func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
if let index = searchState.currentMatchIndex {
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
} else if searchState.matches.isEmpty {
searchBarView.showNoResults()
}
}
/// RDReaderView
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if searchState != nil && !isSearchBarVisible {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}
@@ -0,0 +1,251 @@
import UIKit
// MARK: -
///
/// /
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
// MARK:
///
var onSearchSubmit: ((String) -> Void)?
///
var onSearchPrevious: (() -> Void)?
///
var onSearchNext: (() -> Void)?
///
var onClose: (() -> Void)?
// MARK: UI
private let containerView: UIView = {
let view = UIView()
view.layer.cornerRadius = 8
view.layer.masksToBounds = true
view.isAccessibilityElement = false
view.accessibilityElementsHidden = false
return view
}()
private let searchIcon: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
if #available(iOS 13.0, *) {
imageView.image = UIImage(systemName: "magnifyingglass")
}
imageView.tintColor = .gray
return imageView
}()
let textField: UITextField = {
let field = UITextField()
field.placeholder = "搜索..."
field.font = UIFont.systemFont(ofSize: 15)
field.returnKeyType = .search
field.autocorrectionType = .no
field.autocapitalizationType = .none
field.clearButtonMode = .whileEditing
field.isAccessibilityElement = true
if #available(iOS 13.0, *) {
field.accessibilityTraits = .searchField
}
return field
}()
private let previousButton = RDEPUBReaderTintButton(type: .system)
private let nextButton = RDEPUBReaderTintButton(type: .system)
private let countLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
label.textAlignment = .center
label.setContentHuggingPriority(.required, for: .horizontal)
label.setContentCompressionResistancePriority(.required, for: .horizontal)
return label
}()
private let closeButton = RDEPUBReaderTintButton(type: .system)
// MARK:
private let horizontalInset: CGFloat = 12
private let spacing: CGFloat = 6
private let containerHeight: CGFloat = 36
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.search.bar"
shouldGroupAccessibilityChildren = false
isAccessibilityElement = false
setupSubviews()
setupConstraints()
setupActions()
updateNavigationEnabled(false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK:
override func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
}
override func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
backgroundColor = theme.toolBackgroundColor
containerView.backgroundColor = theme.toolControlBorderUnselectColor
searchIcon.tintColor = theme.toolControlTextColor
textField.textColor = theme.toolControlTextColor
textField.attributedPlaceholder = NSAttributedString(
string: "搜索...",
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
)
countLabel.textColor = theme.toolControlTextColor
previousButton.tintColor = theme.toolControlTextColor
nextButton.tintColor = theme.toolControlTextColor
closeButton.tintColor = theme.toolControlTextColor
}
// MARK:
///
func updateMatchCount(current: Int, total: Int) {
countLabel.text = "\(current)/\(total)"
updateNavigationEnabled(total > 0)
}
///
func showNoResults() {
countLabel.text = "0/0"
updateNavigationEnabled(false)
}
///
func showSearching() {
countLabel.text = "搜索中..."
updateNavigationEnabled(false)
}
///
func restoreKeyword(_ keyword: String) {
textField.text = keyword
}
// MARK:
private func setupSubviews() {
addSubview(containerView)
containerView.addSubview(searchIcon)
containerView.addSubview(textField)
addSubview(previousButton)
addSubview(nextButton)
addSubview(countLabel)
addSubview(closeButton)
if #available(iOS 13.0, *) {
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
previousButton.setTitle("", for: .normal)
nextButton.setTitle("", for: .normal)
closeButton.setTitle("", for: .normal)
}
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
nextButton.accessibilityIdentifier = "epub.reader.search.next"
closeButton.accessibilityIdentifier = "epub.reader.search.close"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
[previousButton, nextButton, closeButton].forEach { button in
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
button.tintColor = .black
button.setTitleColor(.black, for: .normal)
}
}
private func setupConstraints() {
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([
//
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
//
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
searchIcon.widthAnchor.constraint(equalToConstant: 16),
//
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
//
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 32),
previousButton.heightAnchor.constraint(equalToConstant: 32),
//
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 32),
nextButton.heightAnchor.constraint(equalToConstant: 32),
//
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
//
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 32),
closeButton.heightAnchor.constraint(equalToConstant: 32)
])
}
private func setupActions() {
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
}
private func updateNavigationEnabled(_ enabled: Bool) {
previousButton.isEnabled = enabled
previousButton.alpha = enabled ? 1 : 0.45
nextButton.isEnabled = enabled
nextButton.alpha = enabled ? 1 : 0.45
}
@objc private func textFieldDidReturn() {
guard let keyword = textField.text, !keyword.isEmpty else { return }
onSearchSubmit?(keyword)
textField.resignFirstResponder()
}
@objc private func previousAction() {
onSearchPrevious?()
}
@objc private func nextAction() {
onSearchNext?()
}
@objc private func closeAction() {
onClose?()
}
}
@@ -10,8 +10,11 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
var onBack: (() -> Void)?
///
var onToggleBookmark: (() -> Void)?
///
var onSearch: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)
private let searchButton = RDEPUBReaderTintButton(type: .system)
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
private let titleLabel: UILabel = {
let label = UILabel()
@@ -27,14 +30,17 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
accessibilityIdentifier = "epub.reader.topToolbar"
self.backgroundColor = .white
addSubview(backButton)
addSubview(searchButton)
addSubview(bookmarkButton)
addSubview(titleLabel)
backButton.translatesAutoresizingMaskIntoConstraints = false
searchButton.translatesAutoresizingMaskIntoConstraints = false
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
NSLayoutConstraint.activate([
@@ -50,17 +56,26 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
searchButton.widthAnchor.constraint(equalToConstant: 44),
searchButton.heightAnchor.constraint(equalToConstant: 44),
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
])
if #available(iOS 13.0, *) {
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
} else {
backButton.setTitle("返回", for: .normal)
searchButton.setTitle("搜索", for: .normal)
}
backButton.accessibilityIdentifier = "epub.reader.back"
searchButton.accessibilityIdentifier = "epub.reader.search"
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
titleLabel.accessibilityIdentifier = "epub.reader.title"
updateBookmarkButtonAppearance()
@@ -76,12 +91,14 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
override public func apply(theme: RDEPUBReaderTheme) {
super.apply(theme: theme)
titleLabel.textColor = .black
backButton.tintColor = .black
bookmarkButton.tintColor = .black
titleLabel.textColor = theme.toolControlTextColor
backButton.tintColor = theme.toolControlTextColor
searchButton.tintColor = theme.toolControlTextColor
bookmarkButton.tintColor = theme.toolControlTextColor
if #unavailable(iOS 13.0) {
backButton.setTitleColor(.black, for: .normal)
bookmarkButton.setTitleColor(.black, for: .normal)
backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
}
updateBookmarkButtonAppearance()
}
@@ -104,6 +121,10 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
onBack?()
}
@objc private func searchAction() {
onSearch?()
}
@objc private func bookmarkAction() {
onToggleBookmark?()
}
@@ -146,6 +146,19 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
///
/// - Parameter keyword:
public func performDemoSearch(keyword: String) {
guard let readerController else { return }
readerController.showSearchBar()
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
readerController.search(keyword: keyword)
readerController.updateSearchCount()
}
}
///
/// Child View Controller
private func embedReaderController() {
@@ -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 {
@@ -55,6 +55,7 @@ extension RDReaderView {
collectionView.isUserInteractionEnabled = true
pageViewController.view.isUserInteractionEnabled = true
}
onToolViewVisibilityChanged?(isShowToolView)
}
///
@@ -168,6 +168,10 @@ public class RDReaderView: UIView {
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// / 0.3
public var toolViewAnimationDuration: TimeInterval = 0.3
///
var onToolViewVisibilityChanged: ((Bool) -> Void)?
/// tapCenter
var searchBarView: UIView?
///
public var landscapeDualPageEnabled: Bool = false
/// /
@@ -777,6 +781,9 @@ extension RDReaderView: UIGestureRecognizerDelegate {
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
return false
}
return true
}