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
@@ -4,56 +4,12 @@ import Foundation
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
private let backgroundChapterPause: TimeInterval = 0.04
private let incrementalMergeChapterThreshold = 20
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
private let stagedBookRequestLock = NSLock()
private var stagedBookRequest: StagedBookRequest?
private struct QuickBuildState {
var quickWindow: [Int]
var chapterStore: IncrementalChapterStore
var book: RDEPUBTextBook
}
private struct StagedBookRequest {
var chapterStore: IncrementalChapterStore
var orderedSpineIndices: [Int]
var restoreLocation: RDEPUBLocation?
var isComplete: Bool
}
private final class IncrementalChapterStore {
private let lock = NSLock()
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
lock.lock()
builtChapters[spineIndex] = chapter
lock.unlock()
}
func contains(_ spineIndex: Int) -> Bool {
lock.lock()
let contains = builtChapters[spineIndex] != nil
lock.unlock()
return contains
}
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
lock.lock()
let chapters = builtChapters
lock.unlock()
return chapters
}
}
private unowned let context: RDEPUBReaderContext
@@ -61,7 +17,7 @@ final class RDEPUBReaderPaginationCoordinator {
self.context = context
}
/// TextBookBuilderFixed Layout Web Paginator
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
@@ -78,7 +34,7 @@ final class RDEPUBReaderPaginationCoordinator {
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable {
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
print("[EPUB][Pagination] path=text-reflowable-on-demand")
paginateTextPublication(
parser: parser,
publication: publication,
@@ -122,10 +78,8 @@ final class RDEPUBReaderPaginationCoordinator {
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
stagedIncrementalApplyWorkItem?.cancel()
stagedIncrementalApplyWorkItem = nil
clearStagedBookRequest()
context.textBook = textBook
context.bookPageMap = nil
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
@@ -144,6 +98,7 @@ final class RDEPUBReaderPaginationCoordinator {
) {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
@@ -163,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
context.readingSession?.transition(to: .idle)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
@@ -203,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
/// EPUB TextBook
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -212,184 +167,136 @@ final class RDEPUBReaderPaginationCoordinator {
token: UUID
) {
guard let controller = context.controller else { return }
let context = self.context
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
let quickSpineCandidates = prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
var didApplyQuickChapter = false
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
guard prioritizedCandidates.first != nil else {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: RDEPUBParserError.emptySpine)
}
return
}
do {
self.waitForReadingInteractionToSettle()
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
let quickBuildState = try self.buildQuickTextBook(
builder: builder,
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle,
prioritizedCandidates: quickSpineCandidates
guard context.controller != nil,
let runtime = context.runtime else { return }
RDEPUBBackgroundTrace.log(
"QuickOpen",
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
)
if let quickBook = quickBuildState?.book {
DispatchQueue.main.sync {
guard self.context.paginationToken == token else { return }
didApplyQuickChapter = true
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
}
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadFirstRenderableRuntimeChapter"
) {
try self.loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: prioritizedCandidates,
runtime: runtime
)
}
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
var pendingIncrementalCount = 0
let incrementalBuildOrder = self.incrementalBuildOrder(
allBuildableIndices: allBuildableIndices,
quickWindow: quickBuildState?.quickWindow ?? []
)
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
self.waitForReadingInteractionToSettle()
guard let result = try builder.buildChapter(
parser: parser,
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: renderStyle
) else {
continue
}
chapterStore.insert(result.chapter, for: spineIndex)
pendingIncrementalCount += 1
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
pendingIncrementalCount = 0
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: false
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
}
runtime: runtime
)
}
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: true
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
guard context.paginationToken == token,
context.controller != nil else { return }
runtime.chapterRuntimeStore.setCurrentChapter(
spineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
if didApplyQuickChapter {
self.context.isRepaginating = false
self.context.hideLoading()
} else {
self.context.handle(error: error)
}
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: error)
}
}
}
}
private func buildQuickTextBook(
builder: RDEPUBTextBookBuilder,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
prioritizedCandidates: [Int]
) throws -> QuickBuildState? {
var anchorResult: RDEPUBTextChapterBuildResult?
for spineIndex in prioritizedCandidates {
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
), !result.chapter.pages.isEmpty else {
continue
private func loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: [Int],
runtime: RDEPUBReaderRuntime
) throws -> RDEPUBRuntimeChapter {
var lastError: Error?
for spineIndex in prioritizedSpineIndices {
do {
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
} catch {
lastError = error
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
}
anchorResult = result
break
}
throw lastError ?? RDEPUBParserError.emptySpine
}
guard let anchorResult else {
return nil
}
let quickWindow = quickWindowSpineIndices(
around: anchorResult.chapter.spineIndex,
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication
)
let chapterStore = IncrementalChapterStore()
for spineIndex in quickWindow {
let result: RDEPUBTextChapterBuildResult?
if spineIndex == anchorResult.chapter.spineIndex {
result = anchorResult
} else {
result = try builder.buildChapter(
parser: parser,
publication: publication,
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
pageSize: pageSize,
style: style
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
guard let chapter = result?.chapter,
!chapter.pages.isEmpty else {
continue
}
chapterStore.insert(chapter, for: spineIndex)
}
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
return nil
}
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
return QuickBuildState(
quickWindow: quickWindow,
chapterStore: chapterStore,
book: book
)
return chapters
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func quickWindowSpineIndices(
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
@@ -399,7 +306,7 @@ final class RDEPUBReaderPaginationCoordinator {
return [anchorSpineIndex]
}
var selected: [Int] = [anchorSpineIndex]
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
@@ -422,131 +329,209 @@ final class RDEPUBReaderPaginationCoordinator {
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
func scheduleStagedIncrementalTextBookApplication() {
stagedIncrementalApplyWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.applyStagedIncrementalTextBookIfPossible()
}
stagedIncrementalApplyWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
}
private func applyStagedIncrementalTextBookIfPossible() {
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
!context.isRepaginating,
context.readingSession?.navigatorState == .idle else {
scheduleStagedIncrementalTextBookApplication()
return
}
guard let staged = consumeStagedBookRequest() else {
stagedIncrementalApplyWorkItem = nil
return
}
stagedIncrementalApplyWorkItem = nil
guard let stagedBook = textBook(
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
orderedBy: staged.orderedSpineIndices
) else {
return
}
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
}
private func waitForReadingInteractionToSettle() {
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
while context.controller != nil,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func incrementalBuildOrder(
allBuildableIndices: [Int],
quickWindow: [Int]
) -> [Int] {
guard let firstWindowIndex = quickWindow.first,
let lastWindowIndex = quickWindow.last else {
return allBuildableIndices
}
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
return quickWindow + forward + backward
}
private func textBook(
from builtChapters: [Int: RDEPUBTextChapter],
orderedBy spineIndices: [Int]
) -> RDEPUBTextBook? {
var chapters: [RDEPUBTextChapter] = []
var pages: [RDEPUBTextPage] = []
for spineIndex in spineIndices {
guard var chapter = builtChapters[spineIndex],
!chapter.pages.isEmpty else {
continue
}
chapter.chapterIndex = chapters.count
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
var page = page
page.absolutePageIndex = pages.count + localPageIndex
page.chapterIndex = chapters.count
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return page
}
chapter.pages = normalizedPages
chapters.append(chapter)
pages.append(contentsOf: normalizedPages)
}
guard !pages.isEmpty else {
return nil
}
return RDEPUBTextBook(chapters: chapters, pages: pages)
}
private func stageBookRequest(
chapterStore: IncrementalChapterStore,
orderedSpineIndices: [Int],
restoreLocation: RDEPUBLocation?,
isComplete: Bool
) {
stagedBookRequestLock.lock()
stagedBookRequest = StagedBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: orderedSpineIndices,
restoreLocation: restoreLocation,
isComplete: isComplete
)
stagedBookRequestLock.unlock()
}
private func consumeStagedBookRequest() -> StagedBookRequest? {
stagedBookRequestLock.lock()
defer { stagedBookRequestLock.unlock() }
let request = stagedBookRequest
stagedBookRequest = nil
return request
}
private func clearStagedBookRequest() {
stagedBookRequestLock.lock()
stagedBookRequest = nil
stagedBookRequestLock.unlock()
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
let publication = context.publication else { return }
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let style = context.currentTextRenderStyle()
let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
guard context.controller != nil else { return }
let catalog = allBuildableIndices.map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
let restored = summaryDiskCache?.readAll(keys: catalog)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count)"
)
var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder()
var lastAppliedCount = 0
let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys)
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
)
let cachedMap = mapBuilder.build()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
for (offset, spineIndex) in allBuildableIndices.enumerated() {
guard context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return
}
if cachedSpineIndices.contains(spineIndex) {
continue
}
self.waitForReadingInteractionToSettle(using: context)
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)")
let lightweightEntry = try RDEPUBBackgroundTrace.measure(
"MetadataParse",
"spine=\(spineIndex)"
) {
try autoreleasepool { () -> RDEPUBBookPageMapEntry? in
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return nil
}
let chapter = result.chapter
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
summaryDiskCache?.writeSynchronously(summary: summary, for: cacheKey)
return RDEPUBBookPageMapEntry(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.fragmentOffsets
)
}
}
if let lightweightEntry {
mapBuilder.add(
spineIndex: lightweightEntry.spineIndex,
href: lightweightEntry.href,
title: lightweightEntry.title,
pageCount: lightweightEntry.pageCount,
fragmentOffsets: lightweightEntry.fragmentOffsets
)
let builtCount = offset + 1
if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count {
lastAppliedCount = builtCount
let partialMap = mapBuilder.build()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
}
} catch {
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
}
}
let pageMap = mapBuilder.build()
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
return nil
}
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
}