feat: complete large-book pagination runtime and UI coverage

This commit is contained in:
shenlei
2026-06-03 17:47:16 +08:00
parent 584976ac5f
commit feb05eaf87
53 changed files with 6307 additions and 5126 deletions
@@ -6,6 +6,15 @@ import UIKit
final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
return loader
}()
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
@@ -42,9 +51,11 @@ final class RDEPUBReaderRuntime {
context.clearActiveSnapshot()
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
clearOnDemandPageModeState()
viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData()
@@ -84,6 +95,17 @@ final class RDEPUBReaderRuntime {
return true
}
if context.bookPageMap != nil {
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
guard context.activePages.indices.contains(pageNumber - 1) else {
return false
}
@@ -282,6 +304,40 @@ final class RDEPUBReaderRuntime {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
guard let readerView = context.readerView,
let controller = context.controller else { return }
let currentPage = max(readerView.currentPage, 0)
let currentLocation = locationCoordinator.currentVisibleLocation()
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
//
readerView.reloadPageCountOnly()
//
if context.currentSelection == nil, bookPageMap.totalPages > 0 {
readerView.transitionToPage(
pageNum: min(currentPage, max(bookPageMap.totalPages - 1, 0)),
animated: false
)
}
if let currentLocation {
locationCoordinator.persist(location: currentLocation)
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
locationCoordinator.persist(location: resolvedLocation)
}
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
@@ -325,4 +381,174 @@ final class RDEPUBReaderRuntime {
) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
@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
)
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)
}
let adjacent = [spineIndex - 1, spineIndex + 1].filter { publication.spine.indices.contains($0) }
for adjacentSpineIndex in adjacent where chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil {
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
}
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
return
}
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
guard !nextSpineIndices.isEmpty else {
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))"
)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in nextSpineIndices {
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 clearOnDemandPageModeState() {
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
}
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)
}
}