ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
shen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

384 lines
15 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 UIKit
/// coordinator context 访便
///
/// context
/// - parserpublicationtextBookpages
/// - UI configurationbrightness
/// - persistence
/// - 便renderStylelayoutConfig
/// - controller UIKit
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
// MARK: -
/// UIKit
weak var controller: RDEPUBReaderController?
///
weak var readerView: RDReaderView?
///
var dependencies: RDEPUBReaderDependencies = .live
/// 便访
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
// MARK: -
/// EPUB
var parser: RDEPUBParser?
///
var publication: RDEPUBPublication?
///
var readingSession: RDEPUBReadingSession?
///
/// nil ChapterRuntimeStore 访
var textBook: RDEPUBTextBook?
/// 100KB/1000 NSAttributedString
var bookPageMap: RDEPUBBookPageMap?
///
var activeBookmarks: [RDEPUBBookmark] = []
///
var activeHighlights: [RDEPUBHighlight] = []
///
var currentBookIdentifier: String?
///
var paginationToken = UUID()
/// Web
var paginator: RDEPUBPaginator?
///
var searchState: RDEPUBSearchState?
/// BookPageMap
/// map
var pendingFullPageMap: RDEPUBBookPageMap?
///
var lastTextPaginationPageSize: CGSize?
/// OperationQueue
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
/// selectionState
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller
///
var configuration: RDEPUBReaderConfiguration = .default
///
var persistence: RDEPUBReaderPersistence?
/// EPUB URL
var epubURL: URL = URL(string: "about:blank")!
///
var isRepaginating: Bool = false
///
var didStartInitialLoad: Bool = false
///
var isExternalTextBook: Bool = false
/// URL
var textFileURL: URL?
///
var textBookCache = RDEPUBTextBookCache()
// MARK: -
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
}
// MARK: - 便
/// readerView
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
let resolvedSize = containerSize == .zero ? viewSize : containerSize
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
}
///
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
/// readerView
func currentTextPageSize() -> CGSize {
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
if let readerView, let pageNum {
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize
}
}
return currentLayoutContext().viewportSize
}
///
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
}
///
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
//
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
fallbackViewportSize: dependencies.environment.fallbackViewportSize
)
}
///
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
///
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
///
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
///
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
}
///
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
///
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
/// EPUB
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
/// Web
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
/// EPUB
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (currentBookIdentifier ?? "default").sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
let contentHash: String
if let parser,
let publication,
publication.spine.indices.contains(spineIndex) {
let href = publication.spine[spineIndex].href
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
} else {
contentHash = ""
}
return chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
/// 使 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: 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))
}
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
return nil
}
return publication.spine.firstIndex {
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
}
}
///
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
controller?.currentVisibleLocation()
}
///
func persistenceLocation() -> RDEPUBLocation? {
guard let currentBookIdentifier else { return nil }
return persistence?.loadLocation(for: currentBookIdentifier)
}
///
func persist(location: RDEPUBLocation) {
guard let currentBookIdentifier else { return }
persistence?.saveLocation(location, for: currentBookIdentifier)
}
/// /
func markUserNavigationActivity() {
activityLock.lock()
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
activityLock.unlock()
}
/// /
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
activityLock.lock()
let timestamp = lastUserNavigationTimestamp
activityLock.unlock()
guard timestamp > 0 else { return .greatestFiniteMagnitude }
return CFAbsoluteTimeGetCurrent() - timestamp
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil }
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
return textBook.chapters.lazy
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.flatMap { textBook.chapterData(for: $0.href) }
}
///
func showLoading() {
controller?.showLoading()
}
///
func hideLoading() {
controller?.hideLoading()
}
///
func handle(error: Error) {
controller?.handle(error: error)
}
///
func updateReaderChrome() {
controller?.updateReaderChrome()
}
///
func refreshVisibleContentPreservingLocation() {
controller?.refreshVisibleContentPreservingLocation()
}
///
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
controller?.restoreReadingLocation(location, animated: animated) ?? false
}
///
func repaginatePreservingCurrentLocation() {
controller?.repaginatePreservingCurrentLocation()
}
///
func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration()
}
}