feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分 - 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices - RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services Phase 2: Runtime 拆分 - 新增 RDEPUBPresentationRuntime 处理分页状态管理 - 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排 - RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade Phase 0.5: 性能优化 - prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false) - extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载 - RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争 - CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded) - RDEPUBTextPageRenderView 引入静态位图缓存 - RDEPUBTextContentView 新增 loadingSpinner 占位页 Phase 3: 状态机 - 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验) - 新增 RDEPUBPaginationState 记录分页来源 Review 修复 - makeSummary 重复方法合并 - ensureNavigationTargetAvailable 同步路径加注释标记 UI 测试 - 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景) - 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
This commit is contained in:
@@ -1,7 +1,27 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
|
||||
|
||||
public func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||
pageCountOfReaderView(readerView: readerView)
|
||||
}
|
||||
|
||||
public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
|
||||
}
|
||||
|
||||
public func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
|
||||
pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
public func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
topToolView(readerView: readerView)
|
||||
}
|
||||
|
||||
public func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
bottomToolView(readerView: readerView)
|
||||
}
|
||||
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
@@ -9,7 +29,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
_ = runtime.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNum + 1,
|
||||
allowSynchronousLoad: false
|
||||
)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
@@ -34,6 +57,24 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
@@ -307,7 +348,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: effectivePageNum + 1)
|
||||
_ = runtime.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: effectivePageNum + 1,
|
||||
allowSynchronousLoad: false
|
||||
)
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
||||
}
|
||||
|
||||
|
||||
+90
-26
@@ -6,6 +6,8 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
var onDeferredCFIMapReady: ((Int) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
@@ -32,6 +34,11 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
@@ -83,6 +90,11 @@ final class RDEPUBChapterLoader {
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
|
||||
switch priority {
|
||||
case .navigation:
|
||||
@@ -129,6 +141,13 @@ final class RDEPUBChapterLoader {
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -170,6 +189,11 @@ final class RDEPUBChapterLoader {
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pageCount, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
return .success(chapter)
|
||||
}
|
||||
}
|
||||
@@ -300,13 +324,7 @@ final class RDEPUBChapterLoader {
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: typesetString.string
|
||||
),
|
||||
cfiMap: diskSummary?.cfiMap,
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
@@ -459,30 +477,13 @@ final class RDEPUBChapterLoader {
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
|
||||
cfiMap: chapter.cfiMap ?? makeCFIMap(
|
||||
href: chapter.href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
|
||||
chapterText: chapter.attributedContent.string
|
||||
),
|
||||
cfiMap: chapter.cfiMap,
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
@@ -522,6 +523,69 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for chapter: RDEPUBRuntimeChapter,
|
||||
cacheKey: RDEPUBChapterCacheKey,
|
||||
store: RDEPUBChapterRuntimeStore
|
||||
) {
|
||||
guard chapter.chapterOffsetMap.cfiMap == nil,
|
||||
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let spineIndex = chapter.spineIndex
|
||||
let href = chapter.href
|
||||
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
|
||||
let chapterText = chapter.chapterOffsetMap.chapterText
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
|
||||
let cfiMap = self.makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText
|
||||
)
|
||||
chapter.updateCFIMap(cfiMap)
|
||||
self.summaryDiskCache?.write(
|
||||
summary: self.makeSummary(
|
||||
for: chapter.pages,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
|
||||
offsetMap: chapter.chapterOffsetMap,
|
||||
cacheKey: cacheKey
|
||||
),
|
||||
for: cacheKey
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.onDeferredCFIMapReady?(spineIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSummary(
|
||||
for pages: [RDEPUBTextPage],
|
||||
fragmentOffsets: [String: Int],
|
||||
offsetMap: RDEPUBChapterOffsetMap,
|
||||
cacheKey: RDEPUBChapterCacheKey
|
||||
) -> RDEPUBChapterSummary {
|
||||
RDEPUBChapterSummary(
|
||||
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: pages.count,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: pages.map { .from($0.metadata) }
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
|
||||
+30
-2
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterOffsetMap {
|
||||
final class RDEPUBChapterOffsetMap {
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
@@ -8,10 +8,38 @@ struct RDEPUBChapterOffsetMap {
|
||||
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
private var _cfiMap: RDEPUBCFIMap?
|
||||
|
||||
var cfiMap: RDEPUBCFIMap? {
|
||||
cfiMapLock.lock()
|
||||
defer { cfiMapLock.unlock() }
|
||||
return _cfiMap
|
||||
}
|
||||
|
||||
let chapterText: String?
|
||||
|
||||
init(
|
||||
fragmentOffsets: [String: Int],
|
||||
pageStartOffsets: [Int],
|
||||
pageEndOffsets: [Int],
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
chapterText: String?
|
||||
) {
|
||||
self.fragmentOffsets = fragmentOffsets
|
||||
self.pageStartOffsets = pageStartOffsets
|
||||
self.pageEndOffsets = pageEndOffsets
|
||||
self._cfiMap = cfiMap
|
||||
self.chapterText = chapterText
|
||||
}
|
||||
|
||||
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
|
||||
cfiMapLock.lock()
|
||||
_cfiMap = cfiMap
|
||||
cfiMapLock.unlock()
|
||||
}
|
||||
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
+20
@@ -28,6 +28,10 @@ final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
private var buildingCFIMapSpineIndices: Set<Int> = []
|
||||
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
@@ -142,9 +146,25 @@ final class RDEPUBChapterRuntimeStore {
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
|
||||
cfiMapLock.lock()
|
||||
defer { cfiMapLock.unlock() }
|
||||
let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
|
||||
return inserted
|
||||
}
|
||||
|
||||
func endBuildingCFIMap(for spineIndex: Int) {
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.remove(spineIndex)
|
||||
cfiMapLock.unlock()
|
||||
}
|
||||
|
||||
func invalidateAllForSettingsChange() {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
imageCache.removeAllObjects()
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.removeAll()
|
||||
cfiMapLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private unowned let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private unowned let loader: RDEPUBChapterLoader
|
||||
|
||||
private unowned let presentationRuntime: RDEPUBPresentationRuntime
|
||||
|
||||
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
|
||||
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
|
||||
|
||||
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
||||
|
||||
private let refreshVisibleContentPreservingLocation: () -> Void
|
||||
|
||||
private let asyncLoadStateLock = NSLock()
|
||||
|
||||
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
|
||||
|
||||
private var isExtendingPartialBookPageMap = false
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
loader: RDEPUBChapterLoader,
|
||||
presentationRuntime: RDEPUBPresentationRuntime,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
|
||||
jumpSessionManager: RDEPUBJumpSessionManager,
|
||||
refreshVisibleContentPreservingLocation: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
self.presentationRuntime = presentationRuntime
|
||||
self.locationCoordinator = locationCoordinator
|
||||
self.backgroundPriorityManager = backgroundPriorityManager
|
||||
self.jumpSessionManager = jumpSessionManager
|
||||
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(
|
||||
forAbsolutePageNumber pageNumber: Int,
|
||||
allowSynchronousLoad: Bool = true,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let publication = context.publication else {
|
||||
return false
|
||||
}
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if store.chapterData(for: spineIndex) == nil {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: spineIndex,
|
||||
triggerPageNumber: pageNumber,
|
||||
completion: completion
|
||||
)
|
||||
return false
|
||||
}
|
||||
do {
|
||||
_ = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: Int,
|
||||
minimumTrailingPages: Int = 2,
|
||||
batchChapterCount: Int = 3
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = buildableSpineIndices(in: publication)
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
|
||||
)
|
||||
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
let loadedChaptersLock = NSLock()
|
||||
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
let group = DispatchGroup()
|
||||
|
||||
for spineIndex in spineIndicesToAppend {
|
||||
group.enter()
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { result in
|
||||
defer { group.leave() }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
loadedChaptersLock.lock()
|
||||
loadedChapters[spineIndex] = chapter
|
||||
loadedChaptersLock.unlock()
|
||||
case .failure(let error):
|
||||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.endPartialBookPageMapExtension() }
|
||||
self.applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation,
|
||||
currentMap: currentMap,
|
||||
loadedChapters: loadedChapters
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if store.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
|
||||
guard context.bookPageMap != nil,
|
||||
let publication = context.publication,
|
||||
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if let pendingMap = context.pendingFullPageMap,
|
||||
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
|
||||
context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
let chapters = loadPartialWindowChapters(
|
||||
around: anchorPosition,
|
||||
in: buildableIndices,
|
||||
targetSpineIndex: targetSpineIndex,
|
||||
windowSize: normalizedWindowSize
|
||||
)
|
||||
guard !chapters.isEmpty else { return false }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = makePartialPageMap(from: chapters)
|
||||
context.bookPageMap = partialMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.removeAll()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
|
||||
private func applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?,
|
||||
currentMap: RDEPUBBookPageMap,
|
||||
loadedChapters: [Int: RDEPUBRuntimeChapter]
|
||||
) {
|
||||
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
|
||||
guard let chapter = loadedChapters[spineIndex] else { return nil }
|
||||
return RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
guard !appendedEntries.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let combinedEntries = (currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||||
|
||||
var absolutePageStart = 0
|
||||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalized = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalized
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
presentationRuntime.applyExtendedPartialPageMap(
|
||||
newMap,
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: Int,
|
||||
triggerPageNumber: Int,
|
||||
completion: ((Bool) -> Void)?
|
||||
) {
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
)
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .preview
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
self.endAsynchronousChapterPreparation(for: spineIndex)
|
||||
switch result {
|
||||
case .success:
|
||||
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
case .failure(let error):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
for evictable in store.evictableSpineIndices() {
|
||||
store.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||||
)
|
||||
loader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func maybePrefetchUpcomingChapters(
|
||||
aroundAbsolutePageNumber pageNumber: Int,
|
||||
in bookPageMap: RDEPUBBookPageMap,
|
||||
threshold: Int = 3,
|
||||
lookaheadChapterCount: Int = 2
|
||||
) {
|
||||
guard let publication = context.publication else { return }
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = store.chapterData(for: spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let remainingPages = chapter.pages.count - localPageIndex - 1
|
||||
guard remainingPages <= threshold else { return }
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
|
||||
|
||||
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
|
||||
for targetSpineIndex in targets {
|
||||
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(targetSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"boundary prefetch currentSpine=\(spineIndex) targetSpine=\(targetSpineIndex) remainingPages=\(remainingPages)"
|
||||
)
|
||||
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = store.chapterData(for: spineIndex) else { break }
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
|
||||
guard let readerView = context.readerView,
|
||||
let bookPageMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
let visiblePageNumber = readerView.currentPage + 1
|
||||
if visiblePageNumber == triggerPageNumber {
|
||||
refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
guard visiblePageNumber > 0,
|
||||
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
return
|
||||
}
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
// Intentional synchronous path: called from TOC distant jumps where
|
||||
// the user expects immediate navigation. The loading indicator is shown
|
||||
// by the caller. Do NOT convert to async without UX consideration.
|
||||
private func loadPartialWindowChapters(
|
||||
around anchorPosition: Int,
|
||||
in buildableSpineIndices: [Int],
|
||||
targetSpineIndex: Int,
|
||||
windowSize: Int
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
|
||||
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
|
||||
let startIndex = max(0, upperBound - max(windowSize, 1))
|
||||
let window = Array(buildableSpineIndices[startIndex..<upperBound])
|
||||
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in window {
|
||||
do {
|
||||
let chapter = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == targetSpineIndex {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
return []
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
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 buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
|
||||
}
|
||||
|
||||
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.remove(spineIndex)
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
|
||||
private func beginPartialBookPageMapExtension() -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
guard !isExtendingPartialBookPageMap else { return false }
|
||||
isExtendingPartialBookPageMap = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func endPartialBookPageMapExtension() {
|
||||
asyncLoadStateLock.lock()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -45,4 +45,8 @@ final class RDEPUBRuntimeChapter {
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
|
||||
chapterOffsetMap.updateCFIMap(cfiMap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBNavigationState: Equatable {
|
||||
case idle
|
||||
case initialLoading
|
||||
case restoringLocation
|
||||
case preparingChapter(spineIndex: Int)
|
||||
case presentingWindow
|
||||
case reconcilingFullMap
|
||||
case repaginating
|
||||
}
|
||||
|
||||
final class RDEPUBNavigationStateMachine {
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private(set) var state: RDEPUBNavigationState = .idle
|
||||
|
||||
func transition(to newState: RDEPUBNavigationState) {
|
||||
lock.lock()
|
||||
let oldState = state
|
||||
state = newState
|
||||
lock.unlock()
|
||||
#if DEBUG
|
||||
validateTransition(from: oldState, to: newState)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func validateTransition(from oldState: RDEPUBNavigationState, to newState: RDEPUBNavigationState) {
|
||||
let isValid: Bool
|
||||
switch (oldState, newState) {
|
||||
case (.idle, .initialLoading),
|
||||
(.idle, .preparingChapter),
|
||||
(.idle, .restoringLocation),
|
||||
(.idle, .idle):
|
||||
isValid = true
|
||||
case (.initialLoading, .preparingChapter),
|
||||
(.initialLoading, .presentingWindow),
|
||||
(.initialLoading, .idle),
|
||||
(.initialLoading, .initialLoading):
|
||||
isValid = true
|
||||
case (.restoringLocation, .preparingChapter),
|
||||
(.restoringLocation, .presentingWindow),
|
||||
(.restoringLocation, .idle),
|
||||
(.restoringLocation, .restoringLocation):
|
||||
isValid = true
|
||||
case (.preparingChapter, .presentingWindow),
|
||||
(.preparingChapter, .preparingChapter),
|
||||
(.preparingChapter, .idle):
|
||||
isValid = true
|
||||
case (.presentingWindow, .idle),
|
||||
(.presentingWindow, .reconcilingFullMap),
|
||||
(.presentingWindow, .preparingChapter),
|
||||
(.presentingWindow, .presentingWindow),
|
||||
(.presentingWindow, .repaginating):
|
||||
isValid = true
|
||||
case (.reconcilingFullMap, .presentingWindow),
|
||||
(.reconcilingFullMap, .idle),
|
||||
(.reconcilingFullMap, .reconcilingFullMap):
|
||||
isValid = true
|
||||
case (.repaginating, .presentingWindow),
|
||||
(.repaginating, .idle),
|
||||
(.repaginating, .repaginating):
|
||||
isValid = true
|
||||
default:
|
||||
isValid = false
|
||||
}
|
||||
if !isValid {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"NavigationStateMachine",
|
||||
"WARNING: unexpected transition \(oldState) → \(newState)"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBPaginationStateSource: String {
|
||||
case initialPartial
|
||||
case asyncExtension
|
||||
case pendingFullMap
|
||||
case fullReplacement
|
||||
case settingsPreview
|
||||
case cacheRestore
|
||||
case repagination
|
||||
}
|
||||
|
||||
struct RDEPUBPaginationState {
|
||||
|
||||
var activePageMap: RDEPUBBookPageMap?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
var source: RDEPUBPaginationStateSource = .initialPartial
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBPresentationRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
|
||||
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
||||
|
||||
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
|
||||
let navigationStateMachine = RDEPUBNavigationStateMachine()
|
||||
|
||||
private(set) var paginationState = RDEPUBPaginationState()
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
jumpSessionManager: RDEPUBJumpSessionManager,
|
||||
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
) {
|
||||
self.context = context
|
||||
self.locationCoordinator = locationCoordinator
|
||||
self.jumpSessionManager = jumpSessionManager
|
||||
self.reconciliationCoordinator = reconciliationCoordinator
|
||||
}
|
||||
|
||||
func applyBookPageMap(
|
||||
_ bookPageMap: RDEPUBBookPageMap,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
finishPagination: (RDEPUBLocation?) -> Void
|
||||
) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.pendingFullPageMap = nil
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.pendingFullPageMap = nil
|
||||
paginationState.source = .initialPartial
|
||||
finishPagination(restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
if let pendingMap = context.pendingFullPageMap {
|
||||
let shouldKeepExisting =
|
||||
pendingMap.totalChapters > bookPageMap.totalChapters ||
|
||||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
|
||||
pendingMap.totalPages >= bookPageMap.totalPages)
|
||||
if shouldKeepExisting {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
paginationState.pendingFullPageMap = bookPageMap
|
||||
}
|
||||
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
guard !controller.isRepaginating else { return }
|
||||
|
||||
navigationStateMachine.transition(to: .reconcilingFullMap)
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: pendingMap,
|
||||
candidateSegment: nil,
|
||||
currentWindow: context.bookPageMap,
|
||||
jumpSession: jumpSessionManager.activeSession
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .keepCurrentWindow:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
|
||||
|
||||
case .fullReplace(let newPageMap):
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
}
|
||||
}
|
||||
|
||||
func applyExtendedPartialPageMap(
|
||||
_ bookPageMap: RDEPUBBookPageMap,
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let readerView = context.readerView else { return }
|
||||
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.source = .asyncExtension
|
||||
|
||||
readerView.reloadPageCountOnly()
|
||||
if let currentLocation,
|
||||
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
|
||||
return
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationState.activePageMap = bookPageMap
|
||||
paginationState.source = .settingsPreview
|
||||
}
|
||||
|
||||
func clear() {
|
||||
paginationState = RDEPUBPaginationState()
|
||||
navigationStateMachine.transition(to: .idle)
|
||||
}
|
||||
|
||||
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
EPUBPage(
|
||||
spineIndex: entry.spineIndex,
|
||||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: entry.pageCount,
|
||||
chapterTitle: entry.title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
let chapters = bookPageMap.entries.map { entry in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: entry.spineIndex,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
private func applyFullPageMapReplacement(
|
||||
_ newPageMap: RDEPUBBookPageMap,
|
||||
readerView: RDReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) {
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
context.pendingFullPageMap = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = newPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
|
||||
|
||||
paginationState.activePageMap = newPageMap
|
||||
paginationState.pendingFullPageMap = nil
|
||||
paginationState.source = .fullReplacement
|
||||
navigationStateMachine.transition(to: .presentingWindow)
|
||||
|
||||
if let currentLocation {
|
||||
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()
|
||||
}
|
||||
|
||||
if let currentLocation,
|
||||
context.normalizedSpineIndex(for: currentLocation) != nil,
|
||||
let activeSession = jumpSessionManager.activeSession {
|
||||
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
|
||||
let protectedIndices = activeSession.protectedSpineIndices
|
||||
if protectedIndices.isSubset(of: candidateIndices) {
|
||||
jumpSessionManager.endSession(.coverageComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
#if DEBUG
|
||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
print("[ReadViewDemo] assembleInterface: pageProvider=\(readerView.pageProvider != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
readerView.dataSource = context.controller
|
||||
readerView.pageProvider = context.controller
|
||||
readerView.delegate = context.controller
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
|
||||
@@ -10,54 +10,108 @@ final class RDEPUBReaderContext {
|
||||
|
||||
weak var readerView: RDReaderView?
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies = .live
|
||||
let state: RDEPUBReaderState
|
||||
|
||||
let environment: RDEPUBReaderEnvironment
|
||||
|
||||
let services: RDEPUBReaderServices
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies {
|
||||
get { services.dependencies }
|
||||
set {
|
||||
services.dependencies = newValue
|
||||
environment.displayEnvironment = newValue.environment
|
||||
}
|
||||
}
|
||||
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
|
||||
var publication: RDEPUBPublication?
|
||||
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
|
||||
var textBook: RDEPUBTextBook?
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
var currentBookIdentifier: String?
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
var paginator: RDEPUBPaginator?
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
if let newValue, !newValue.isEmpty {
|
||||
selectionState = .selected(newValue)
|
||||
} else {
|
||||
selectionState = .idle
|
||||
}
|
||||
}
|
||||
var parser: RDEPUBParser? {
|
||||
get { state.parser }
|
||||
set { state.parser = newValue }
|
||||
}
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
var publication: RDEPUBPublication? {
|
||||
get { state.publication }
|
||||
set { state.publication = newValue }
|
||||
}
|
||||
|
||||
var readingSession: RDEPUBReadingSession? {
|
||||
get { state.readingSession }
|
||||
set { state.readingSession = newValue }
|
||||
}
|
||||
|
||||
var textBook: RDEPUBTextBook? {
|
||||
get { state.textBook }
|
||||
set { state.textBook = newValue }
|
||||
}
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap? {
|
||||
get { state.bookPageMap }
|
||||
set { state.bookPageMap = newValue }
|
||||
}
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] {
|
||||
get { state.activeBookmarks }
|
||||
set { state.activeBookmarks = newValue }
|
||||
}
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] {
|
||||
get { state.activeHighlights }
|
||||
set { state.activeHighlights = newValue }
|
||||
}
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
get { state.currentBookIdentifier }
|
||||
set { state.currentBookIdentifier = newValue }
|
||||
}
|
||||
|
||||
var paginationToken: UUID {
|
||||
get { state.paginationToken }
|
||||
set { state.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var paginator: RDEPUBPaginator? {
|
||||
get { state.paginator }
|
||||
set { state.paginator = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { state.searchState }
|
||||
set { state.searchState = newValue }
|
||||
}
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap? {
|
||||
get { state.pendingFullPageMap }
|
||||
set { state.pendingFullPageMap = newValue }
|
||||
}
|
||||
|
||||
var lastTextPaginationPageSize: CGSize? {
|
||||
get { state.lastTextPaginationPageSize }
|
||||
set { state.lastTextPaginationPageSize = newValue }
|
||||
}
|
||||
|
||||
var lastMetadataParseWallClockMs: Int {
|
||||
get { state.lastMetadataParseWallClockMs }
|
||||
set { state.lastMetadataParseWallClockMs = newValue }
|
||||
}
|
||||
|
||||
var lastMetadataParseConcurrency: Int {
|
||||
get { state.lastMetadataParseConcurrency }
|
||||
set { state.lastMetadataParseConcurrency = newValue }
|
||||
}
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { state.currentSelection }
|
||||
set { state.currentSelection = newValue }
|
||||
}
|
||||
|
||||
var selectionState: RDEPUBSelectionState {
|
||||
get { state.selectionState }
|
||||
set { state.selectionState = newValue }
|
||||
}
|
||||
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
|
||||
@@ -65,32 +119,43 @@ final class RDEPUBReaderContext {
|
||||
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
var isRepaginating: Bool {
|
||||
get { state.isRepaginating }
|
||||
set { state.isRepaginating = newValue }
|
||||
}
|
||||
|
||||
var didStartInitialLoad: Bool = false
|
||||
var didStartInitialLoad: Bool {
|
||||
get { state.didStartInitialLoad }
|
||||
set { state.didStartInitialLoad = newValue }
|
||||
}
|
||||
|
||||
var isExternalTextBook: Bool = false
|
||||
var isExternalTextBook: Bool {
|
||||
get { state.isExternalTextBook }
|
||||
set { state.isExternalTextBook = newValue }
|
||||
}
|
||||
|
||||
var textFileURL: URL?
|
||||
var textFileURL: URL? {
|
||||
get { state.textFileURL }
|
||||
set { state.textFileURL = newValue }
|
||||
}
|
||||
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
self.readerView = controller.readerView
|
||||
let state = RDEPUBReaderState()
|
||||
self.state = state
|
||||
self.environment = RDEPUBReaderEnvironment(
|
||||
controller: controller,
|
||||
readerView: controller.readerView,
|
||||
displayEnvironment: RDEPUBUIScreenEnvironment()
|
||||
)
|
||||
self.services = RDEPUBReaderServices(dependencies: .live)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
environment.currentLayoutContext(configuration: configuration)
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
@@ -122,82 +187,60 @@ final class RDEPUBReaderContext {
|
||||
return mainThreadSize
|
||||
}
|
||||
}
|
||||
return dependencies.environment.fallbackViewportSize
|
||||
return environment.fallbackViewportSize
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
environment.currentTextRenderStyle(configuration: configuration)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
services.resolvedTextRenderer(configuration: configuration)
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
state.activePages
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
state.activeChapters
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { dependencies.environment.currentBrightness }
|
||||
set { dependencies.environment.currentBrightness = newValue }
|
||||
get { environment.currentBrightness }
|
||||
set { environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
state.replaceActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
state.clearActiveSnapshot()
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
services.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
services.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
services.makeTextBookBuilder(
|
||||
configuration: configuration,
|
||||
cache: textBookCache,
|
||||
layoutConfig: 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)
|
||||
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
@@ -272,7 +315,10 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
services.makePlainTextBookBuilder(
|
||||
configuration: configuration,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderEnvironment {
|
||||
|
||||
weak var controller: RDEPUBReaderController?
|
||||
|
||||
weak var readerView: RDReaderView?
|
||||
|
||||
var displayEnvironment: any RDEPUBReaderDisplayEnvironment
|
||||
|
||||
init(
|
||||
controller: RDEPUBReaderController,
|
||||
readerView: RDReaderView,
|
||||
displayEnvironment: any RDEPUBReaderDisplayEnvironment
|
||||
) {
|
||||
self.controller = controller
|
||||
self.readerView = readerView
|
||||
self.displayEnvironment = displayEnvironment
|
||||
}
|
||||
|
||||
func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> 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 currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> 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(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
pageSize: CGSize
|
||||
) -> RDEPUBTextLayoutConfig {
|
||||
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: displayEnvironment.fallbackViewportSize
|
||||
)
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { displayEnvironment.currentBrightness }
|
||||
set { displayEnvironment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
var fallbackViewportSize: CGSize {
|
||||
displayEnvironment.fallbackViewportSize
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
controller.isRepaginating = true
|
||||
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .repaginating)
|
||||
controller.errorLabel.isHidden = true
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
@@ -181,6 +182,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
controller.isRepaginating = false
|
||||
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
controller.hideLoading()
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
|
||||
@@ -11,6 +11,9 @@ final class RDEPUBReaderRuntime {
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
|
||||
self?.handleDeferredCFIMapReady(for: spineIndex)
|
||||
}
|
||||
return loader
|
||||
}()
|
||||
|
||||
@@ -38,6 +41,26 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
|
||||
|
||||
lazy var presentationRuntime = RDEPUBPresentationRuntime(
|
||||
context: context,
|
||||
locationCoordinator: locationCoordinator,
|
||||
jumpSessionManager: jumpSessionManager,
|
||||
reconciliationCoordinator: reconciliationCoordinator
|
||||
)
|
||||
|
||||
lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
|
||||
context: context,
|
||||
store: chapterRuntimeStore,
|
||||
loader: chapterLoader,
|
||||
presentationRuntime: presentationRuntime,
|
||||
locationCoordinator: locationCoordinator,
|
||||
backgroundPriorityManager: backgroundPriorityManager,
|
||||
jumpSessionManager: jumpSessionManager,
|
||||
refreshVisibleContentPreservingLocation: { [weak self] in
|
||||
self?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
)
|
||||
|
||||
var isSettingsPanelOpen: Bool = false
|
||||
|
||||
var needsFullRepaginationAfterSettingsClose: Bool = false
|
||||
@@ -99,7 +122,7 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
guard context.controller != nil,
|
||||
let readerView = context.readerView,
|
||||
pageNumber > 0 else {
|
||||
return false
|
||||
@@ -313,90 +336,20 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
presentationRuntime.applyBookPageMap(
|
||||
bookPageMap,
|
||||
restoreLocation: restoreLocation
|
||||
) { [weak self] restoreLocation in
|
||||
self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
if let pendingMap = context.pendingFullPageMap {
|
||||
let shouldKeepExisting =
|
||||
pendingMap.totalChapters > bookPageMap.totalChapters ||
|
||||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
|
||||
pendingMap.totalPages >= bookPageMap.totalPages)
|
||||
if shouldKeepExisting {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
|
||||
}
|
||||
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
guard !controller.isRepaginating else { return }
|
||||
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: pendingMap,
|
||||
candidateSegment: nil,
|
||||
currentWindow: context.bookPageMap,
|
||||
jumpSession: jumpSessionManager.activeSession
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .keepCurrentWindow:
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
|
||||
return
|
||||
|
||||
case .fullReplace(let newPageMap):
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFullPageMapReplacement(
|
||||
_ newPageMap: RDEPUBBookPageMap,
|
||||
readerView: RDReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) {
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
context.pendingFullPageMap = nil
|
||||
|
||||
context.textBook = nil
|
||||
context.bookPageMap = newPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
|
||||
|
||||
if let currentLocation {
|
||||
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()
|
||||
}
|
||||
|
||||
if let currentLocation,
|
||||
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
|
||||
let activeSession = jumpSessionManager.activeSession {
|
||||
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
|
||||
let protectedIndices = activeSession.protectedSpineIndices
|
||||
let isFullyCovered = protectedIndices.isSubset(of: candidateIndices)
|
||||
if isFullyCovered {
|
||||
jumpSessionManager.endSession(.coverageComplete)
|
||||
}
|
||||
}
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
@@ -497,8 +450,7 @@ final class RDEPUBReaderRuntime {
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
let partialMap = self.makePartialPageMap(from: [chapter])
|
||||
self.context.bookPageMap = partialMap
|
||||
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
|
||||
self.presentationRuntime.applySettingsPreviewPageMap(partialMap)
|
||||
readerView.reloadData()
|
||||
|
||||
if let targetPage = self.settingsPreviewTargetPage(
|
||||
@@ -614,282 +566,39 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
@discardableResult
|
||||
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
|
||||
guard context.bookPageMap != nil,
|
||||
let publication = context.publication,
|
||||
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if let pendingMap = context.pendingFullPageMap,
|
||||
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
applyPendingFullPageMapIfNeeded()
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
|
||||
context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
let chapters = loadPartialWindowChapters(
|
||||
around: anchorPosition,
|
||||
in: buildableSpineIndices,
|
||||
targetSpineIndex: targetSpineIndex,
|
||||
windowSize: normalizedWindowSize
|
||||
)
|
||||
guard !chapters.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = makePartialPageMap(from: chapters)
|
||||
context.bookPageMap = partialMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let publication = context.publication else {
|
||||
return false
|
||||
}
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
func prepareOnDemandChapter(
|
||||
forAbsolutePageNumber pageNumber: Int,
|
||||
allowSynchronousLoad: Bool = true,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
chapterWarmupOrchestrator.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNumber,
|
||||
allowSynchronousLoad: allowSynchronousLoad,
|
||||
completion: completion
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
|
||||
do {
|
||||
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for evictable in chapterRuntimeStore.evictableSpineIndices() {
|
||||
chapterRuntimeStore.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { _ in }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
guard !spineIndicesToAppend.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
|
||||
func extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: Int,
|
||||
minimumTrailingPages: Int = 2,
|
||||
batchChapterCount: Int = 3
|
||||
) {
|
||||
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: currentPageNumber,
|
||||
minimumTrailingPages: minimumTrailingPages,
|
||||
batchChapterCount: batchChapterCount
|
||||
)
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in spineIndicesToAppend {
|
||||
do {
|
||||
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let combinedEntries = (currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||||
|
||||
var absolutePageStart = 0
|
||||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalized = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalized
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadData()
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount
|
||||
)
|
||||
|
||||
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
|
||||
chapterRuntimeStore.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"initial open prefetch forward spine=\(spineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
@@ -900,6 +609,8 @@ final class RDEPUBReaderRuntime {
|
||||
jumpSessionManager.clearSession()
|
||||
backgroundPriorityManager.reset()
|
||||
backgroundCoverageStore.clearAll()
|
||||
chapterWarmupOrchestrator.clear()
|
||||
presentationRuntime.clear()
|
||||
}
|
||||
|
||||
func handleMemoryWarning() {
|
||||
@@ -918,42 +629,6 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private func loadPartialWindowChapters(
|
||||
around anchorPosition: Int,
|
||||
in buildableSpineIndices: [Int],
|
||||
targetSpineIndex: Int,
|
||||
windowSize: Int
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
|
||||
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
|
||||
let startIndex = max(0, upperBound - max(windowSize, 1))
|
||||
let window = Array(buildableSpineIndices[startIndex..<upperBound])
|
||||
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in window {
|
||||
do {
|
||||
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == targetSpineIndex {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
return []
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
@@ -968,96 +643,12 @@ final class RDEPUBReaderRuntime {
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
private func handleDeferredCFIMapReady(for spineIndex: Int) {
|
||||
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
|
||||
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else {
|
||||
break
|
||||
}
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
EPUBPage(
|
||||
spineIndex: entry.spineIndex,
|
||||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: entry.pageCount,
|
||||
chapterTitle: entry.title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
let chapters = bookPageMap.entries.map { entry in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: entry.spineIndex,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderServices {
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies
|
||||
|
||||
init(dependencies: RDEPUBReaderDependencies) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
cache: RDEPUBTextBookCache?,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(
|
||||
resolvedTextRenderer(configuration: configuration),
|
||||
cache,
|
||||
layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(
|
||||
resolvedTextRenderer(configuration: configuration),
|
||||
layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (bookIdentifier ?? "default").sha256Hex
|
||||
let directory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderState {
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
|
||||
var publication: RDEPUBPublication?
|
||||
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
|
||||
var textBook: RDEPUBTextBook?
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
var currentBookIdentifier: String?
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
var paginator: RDEPUBPaginator?
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
|
||||
var didStartInitialLoad: Bool = false
|
||||
|
||||
var isExternalTextBook: Bool = false
|
||||
|
||||
var textFileURL: URL?
|
||||
|
||||
let textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
if let newValue, !newValue.isEmpty {
|
||||
selectionState = .selected(newValue)
|
||||
} else {
|
||||
selectionState = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return label
|
||||
}()
|
||||
|
||||
private let loadingSpinner: UIActivityIndicatorView = {
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.hidesWhenStopped = true
|
||||
spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
|
||||
return spinner
|
||||
}()
|
||||
|
||||
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
||||
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
gesture.minimumPressDuration = 0.5
|
||||
@@ -136,6 +143,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(loadingSpinner)
|
||||
addSubview(selectionLoupeView)
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
@@ -214,6 +222,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
}
|
||||
|
||||
func configure(
|
||||
@@ -226,6 +235,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
loadingSpinner.stopAnimating()
|
||||
currentPage = page
|
||||
currentChapterCFIMap = chapterCFIMap
|
||||
currentChapterFragmentOffsets = chapterFragmentOffsets
|
||||
@@ -297,6 +307,44 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func configureLoading(
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) {
|
||||
currentPage = nil
|
||||
currentChapterCFIMap = nil
|
||||
currentChapterFragmentOffsets = [:]
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
currentHighlights = []
|
||||
currentSearchState = nil
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextContentView.attributedDisplayContent = nil
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
#endif
|
||||
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
loadingSpinner.startAnimating()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
|
||||
@@ -12,19 +12,19 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
invalidateStaticContent()
|
||||
}
|
||||
}
|
||||
|
||||
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
invalidateStaticContent()
|
||||
}
|
||||
}
|
||||
|
||||
var attributedDisplayContent: NSAttributedString? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
invalidateStaticContent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
private let selectionHandleHitSlop: CGFloat = 20
|
||||
|
||||
private var cachedStaticImage: UIImage?
|
||||
|
||||
private var cachedStaticBoundsSize: CGSize = .zero
|
||||
|
||||
private var needsStaticContentRedraw = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
@@ -61,17 +67,35 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
context.saveGState()
|
||||
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
if cachedStaticImage == nil
|
||||
|| cachedStaticBoundsSize != bounds.size
|
||||
|| needsStaticContentRedraw {
|
||||
cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
|
||||
cachedStaticBoundsSize = bounds.size
|
||||
needsStaticContentRedraw = false
|
||||
}
|
||||
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
if let cachedStaticImage {
|
||||
cachedStaticImage.draw(in: bounds)
|
||||
} else {
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
}
|
||||
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if cachedStaticBoundsSize != bounds.size {
|
||||
invalidateStaticContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func drawHighlights(
|
||||
in context: CGContext,
|
||||
attributedString: NSAttributedString,
|
||||
@@ -223,6 +247,26 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
private func invalidateStaticContent() {
|
||||
cachedStaticImage = nil
|
||||
needsStaticContentRedraw = true
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.opaque = false
|
||||
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
||||
return renderer.image { _ in
|
||||
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
layoutFrame.draw(in: staticContext, options: drawOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGPoint {
|
||||
|
||||
Reference in New Issue
Block a user