feat(reader): 实现大书快速进入与增量分页路径
- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建 与 staged 合并策略,避免整书一次性构建的主线程阻塞 - TextBookBuilder: 重构为支持单章构建与增量合并模式 - ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存 - PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能 - ReaderContext/ReaderRuntime: 适配增量分页状态管理 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
488350d956
commit
c76beed03c
@@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.toc.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.toc.table"
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
@@ -62,4 +65,4 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
guard let currentItem else { return false }
|
||||
return currentItem.href == item.href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
|
||||
@@ -247,6 +247,8 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
applyTheme()
|
||||
@@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
|
||||
@@ -25,6 +25,12 @@ import UIKit
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private struct PendingDemoPageRequest {
|
||||
let pageNumber: Int
|
||||
let animated: Bool
|
||||
var attemptCount: Int
|
||||
}
|
||||
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
@@ -41,6 +47,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
label.text = "reader=opening"
|
||||
return label
|
||||
}()
|
||||
private var pendingDemoPageRequest: PendingDemoPageRequest?
|
||||
private var isRetryingPendingDemoPage = false
|
||||
private let maxPendingDemoPageAttempts = 24
|
||||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@@ -88,9 +98,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||||
if moved {
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
} else {
|
||||
queuePendingDemoPageNavigation(pageNumber, animated: animated)
|
||||
}
|
||||
return moved
|
||||
}
|
||||
@@ -187,6 +200,73 @@ public final class RDURLReaderController: UIViewController {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
|
||||
let numPages = readerController.readerView.numberOfPages()
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||||
guard pageNumber <= totalPages else { return false }
|
||||
|
||||
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
return true
|
||||
}
|
||||
|
||||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||||
if let pendingDemoPageRequest,
|
||||
pendingDemoPageRequest.pageNumber == pageNumber,
|
||||
pendingDemoPageRequest.animated == animated {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest = PendingDemoPageRequest(
|
||||
pageNumber: pageNumber,
|
||||
animated: animated,
|
||||
attemptCount: 0
|
||||
)
|
||||
retryPendingDemoPageIfNeeded()
|
||||
}
|
||||
|
||||
private func retryPendingDemoPageIfNeeded() {
|
||||
guard !isRetryingPendingDemoPage else { return }
|
||||
guard pendingDemoPageRequest != nil else { return }
|
||||
|
||||
isRetryingPendingDemoPage = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptPendingDemoPageNavigation() {
|
||||
guard var pendingDemoPageRequest else {
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest.attemptCount += 1
|
||||
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingDemoPageRequest = pendingDemoPageRequest
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
@@ -259,7 +339,13 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
|
||||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,13 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
@@ -37,7 +39,14 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
|
||||
@@ -9,6 +9,9 @@ import UIKit
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
private let activityLock = NSLock()
|
||||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||||
|
||||
// MARK: - 引用
|
||||
|
||||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||||
@@ -205,6 +208,22 @@ final class RDEPUBReaderContext {
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 记录最近一次用户翻页/跳转行为,用于后台分页让路。
|
||||
func markUserNavigationActivity() {
|
||||
activityLock.lock()
|
||||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||||
activityLock.unlock()
|
||||
}
|
||||
|
||||
/// 距离最近一次用户翻页/跳转已经过去的时间。
|
||||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||||
activityLock.lock()
|
||||
let timestamp = lastUserNavigationTimestamp
|
||||
activityLock.unlock()
|
||||
guard timestamp > 0 else { return .greatestFiniteMagnitude }
|
||||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据。
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
|
||||
+408
-27
@@ -9,6 +9,52 @@ import Foundation
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
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
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -29,38 +75,22 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation,
|
||||
token: token
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
print("[EPUB][Pagination] path=fixed-layout")
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
@@ -71,6 +101,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
print("[EPUB][Pagination] path=web-paginator")
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
@@ -91,6 +122,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
clearStagedBookRequest()
|
||||
context.textBook = textBook
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
@@ -108,7 +142,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -168,4 +202,351 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: true
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
anchorResult = result
|
||||
break
|
||||
}
|
||||
|
||||
guard let anchorResult else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let quickWindow = quickWindowSpineIndices(
|
||||
around: anchorResult.chapter.spineIndex,
|
||||
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,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
) -> [Int] {
|
||||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected: [Int] = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
while selected.count < maxChapterCount,
|
||||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||||
if nextPosition < buildableIndices.count {
|
||||
selected.append(buildableIndices[nextPosition])
|
||||
nextPosition += 1
|
||||
if selected.count == maxChapterCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if previousPosition >= 0 {
|
||||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||||
previousPosition -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
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 {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +73,15 @@ final class RDEPUBReaderRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook != nil {
|
||||
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
if let textBook = context.textBook {
|
||||
guard textBook.page(at: pageNumber) != nil else {
|
||||
return false
|
||||
}
|
||||
return locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user