- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
1064 lines
38 KiB
Swift
1064 lines
38 KiB
Swift
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)
|
|
|
|
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
|
|
|
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
|
|
|
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
|
|
|
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
|
|
|
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
|
|
|
|
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
|
|
|
|
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
|
|
|
|
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
|
|
|
|
var isSettingsPanelOpen: Bool = false
|
|
|
|
var needsFullRepaginationAfterSettingsClose: Bool = false
|
|
|
|
private var settingsPreviewGeneration: Int = 0
|
|
|
|
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
|
|
|
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
|
|
|
private struct SettingsPreviewAnchor {
|
|
|
|
let spineIndex: Int
|
|
|
|
let href: String
|
|
|
|
let offset: Int
|
|
}
|
|
|
|
init(context: RDEPUBReaderContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
|
chromeCoordinator.makeTopToolView()
|
|
}
|
|
|
|
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
|
chromeCoordinator.makeBottomToolView()
|
|
}
|
|
|
|
func startInitialLoadIfNeeded() {
|
|
loadCoordinator.startInitialLoadIfNeeded()
|
|
}
|
|
|
|
func reloadBook() {
|
|
guard let readerView = context.readerView else { return }
|
|
context.didStartInitialLoad = false
|
|
context.parser = nil
|
|
context.publication = nil
|
|
context.clearActiveSnapshot()
|
|
context.readingSession = nil
|
|
context.textBook = nil
|
|
context.bookPageMap = nil
|
|
context.pendingFullPageMap = nil
|
|
context.activeBookmarks = []
|
|
context.activeHighlights = []
|
|
context.searchState = nil
|
|
clearOnDemandPageModeState()
|
|
viewportMonitor.resetForReload()
|
|
annotationCoordinator.updateCurrentSelection(nil)
|
|
readerView.reloadData()
|
|
startInitialLoadIfNeeded()
|
|
}
|
|
|
|
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
|
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
|
}
|
|
|
|
@discardableResult
|
|
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
|
guard let controller = context.controller,
|
|
let readerView = context.readerView,
|
|
pageNumber > 0 else {
|
|
return false
|
|
}
|
|
|
|
if let textBook = context.textBook {
|
|
guard textBook.page(at: pageNumber) != nil else {
|
|
return false
|
|
}
|
|
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
|
if let location = locationCoordinator.currentVisibleLocation() {
|
|
context.persist(location: location)
|
|
}
|
|
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
|
|
}
|
|
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
|
if let location = locationCoordinator.currentVisibleLocation() {
|
|
context.persist(location: location)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func clearSelection() {
|
|
annotationCoordinator.updateCurrentSelection(nil)
|
|
}
|
|
|
|
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
|
annotationCoordinator.bookmark(withID: id)
|
|
}
|
|
|
|
func highlight(withID id: String) -> RDEPUBHighlight? {
|
|
annotationCoordinator.highlight(withID: id)
|
|
}
|
|
|
|
@discardableResult
|
|
func addHighlight(
|
|
from selection: RDEPUBSelection? = nil,
|
|
color: String = "#F8E16C",
|
|
note: String? = nil
|
|
) -> RDEPUBHighlight? {
|
|
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
|
|
}
|
|
|
|
@discardableResult
|
|
func addAnnotation(
|
|
from selection: RDEPUBSelection? = nil,
|
|
style: RDEPUBHighlightStyle,
|
|
color: String = "#F8E16C",
|
|
note: String? = nil
|
|
) -> RDEPUBHighlight? {
|
|
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
|
|
}
|
|
|
|
@discardableResult
|
|
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
|
annotationCoordinator.upsertHighlight(highlight)
|
|
}
|
|
|
|
@discardableResult
|
|
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
|
annotationCoordinator.removeHighlight(id: id)
|
|
}
|
|
|
|
@discardableResult
|
|
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
|
annotationCoordinator.updateHighlightNote(id: id, note: note)
|
|
}
|
|
|
|
@discardableResult
|
|
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
|
annotationCoordinator.go(toHighlightID: id, animated: animated)
|
|
}
|
|
|
|
func removeAllHighlights() {
|
|
annotationCoordinator.removeAllHighlights()
|
|
}
|
|
|
|
@discardableResult
|
|
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
|
annotationCoordinator.addBookmark(note: note)
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
|
annotationCoordinator.toggleBookmark(note: note)
|
|
}
|
|
|
|
@discardableResult
|
|
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
|
annotationCoordinator.removeBookmark(id: id)
|
|
}
|
|
|
|
@discardableResult
|
|
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
|
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
|
}
|
|
|
|
func presentBookmarksManager() {
|
|
annotationCoordinator.presentBookmarksManager()
|
|
}
|
|
|
|
func presentHighlightsManager() {
|
|
annotationCoordinator.presentHighlightsManager()
|
|
}
|
|
|
|
func presentAnnotationCreation() {
|
|
annotationCoordinator.presentAnnotationCreation()
|
|
}
|
|
|
|
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
|
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
|
}
|
|
|
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
|
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
|
}
|
|
|
|
func search(keyword: String) {
|
|
searchCoordinator.search(keyword: keyword)
|
|
}
|
|
|
|
@discardableResult
|
|
func searchNext() -> Bool {
|
|
searchCoordinator.searchNext()
|
|
}
|
|
|
|
@discardableResult
|
|
func searchPrevious() -> Bool {
|
|
searchCoordinator.searchPrevious()
|
|
}
|
|
|
|
@discardableResult
|
|
func selectSearchMatch(at index: Int) -> Bool {
|
|
searchCoordinator.selectSearchMatch(at: index)
|
|
}
|
|
|
|
func clearSearch() {
|
|
searchCoordinator.clearSearch()
|
|
}
|
|
|
|
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
|
searchCoordinator.searchPresentation(for: page)
|
|
}
|
|
|
|
func updateReaderChrome() {
|
|
chromeCoordinator.updateReaderChrome()
|
|
}
|
|
|
|
func presentSettings() {
|
|
chromeCoordinator.presentSettings()
|
|
}
|
|
|
|
func presentTableOfContents() {
|
|
chromeCoordinator.presentTableOfContents()
|
|
}
|
|
|
|
func handleBackAction() {
|
|
chromeCoordinator.handleBackAction()
|
|
}
|
|
|
|
func loadPublication() {
|
|
loadCoordinator.loadPublication()
|
|
}
|
|
|
|
func applyParsedPublication(
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
bookIdentifier: String,
|
|
restoreLocation: RDEPUBLocation?,
|
|
bookmarks: [RDEPUBBookmark],
|
|
highlights: [RDEPUBHighlight]
|
|
) {
|
|
loadCoordinator.applyParsedPublication(
|
|
parser: parser,
|
|
publication: publication,
|
|
bookIdentifier: bookIdentifier,
|
|
restoreLocation: restoreLocation,
|
|
bookmarks: bookmarks,
|
|
highlights: highlights
|
|
)
|
|
}
|
|
|
|
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
|
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
|
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func applyPaginationSnapshot(
|
|
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
|
restoreLocation: RDEPUBLocation?
|
|
) {
|
|
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) {
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
|
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func repaginatePreservingCurrentLocation() {
|
|
|
|
if isSettingsPanelOpen {
|
|
needsFullRepaginationAfterSettingsClose = true
|
|
paginationCoordinator.cancelActiveMetadataParseWork()
|
|
scheduleSettingsPreviewRepagination()
|
|
} else {
|
|
paginationCoordinator.repaginatePreservingCurrentLocation()
|
|
}
|
|
}
|
|
|
|
private func scheduleSettingsPreviewRepagination() {
|
|
pendingSettingsPreviewWorkItem?.cancel()
|
|
settingsPreviewGeneration += 1
|
|
let previewGeneration = settingsPreviewGeneration
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
guard let self,
|
|
self.isSettingsPanelOpen,
|
|
previewGeneration == self.settingsPreviewGeneration else {
|
|
return
|
|
}
|
|
self.pendingSettingsPreviewWorkItem = nil
|
|
let previewAnchor = self.captureSettingsPreviewAnchor()
|
|
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
|
self.repaginateCurrentChapterOnly(
|
|
previewGeneration: previewGeneration,
|
|
previewAnchor: previewAnchor
|
|
)
|
|
}
|
|
pendingSettingsPreviewWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(
|
|
deadline: .now() + settingsPreviewDebounceDelay,
|
|
execute: workItem
|
|
)
|
|
}
|
|
|
|
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
|
|
guard let bookPageMap = context.bookPageMap,
|
|
let readerView = context.readerView else { return nil }
|
|
|
|
let absolutePageIndex = readerView.currentPage
|
|
guard absolutePageIndex >= 0,
|
|
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
|
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
|
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
|
|
chapter.pages.indices.contains(localPageIndex) else {
|
|
return nil
|
|
}
|
|
|
|
let page = chapter.pages[localPageIndex]
|
|
let offset = page.contentRange.length > 0
|
|
? page.contentRange.location
|
|
: page.pageStartOffset
|
|
return SettingsPreviewAnchor(
|
|
spineIndex: spineIndex,
|
|
href: chapter.href,
|
|
offset: offset
|
|
)
|
|
}
|
|
|
|
private func repaginateCurrentChapterOnly(
|
|
previewGeneration: Int,
|
|
previewAnchor: SettingsPreviewAnchor?
|
|
) {
|
|
guard let bookPageMap = context.bookPageMap,
|
|
let readerView = context.readerView else { return }
|
|
|
|
let currentPageNumber = readerView.currentPage + 1
|
|
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
|
|
return
|
|
}
|
|
let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation()
|
|
|
|
RDEPUBBackgroundTrace.log(
|
|
"Runtime",
|
|
"repaginateCurrentChapterOnly spine=\(currentSpineIndex) generation=\(previewGeneration)"
|
|
)
|
|
|
|
chapterLoader.loadChapter(
|
|
spineIndex: currentSpineIndex,
|
|
store: chapterRuntimeStore,
|
|
priority: .preview
|
|
) { [weak self] result in
|
|
guard let self,
|
|
self.isSettingsPanelOpen,
|
|
previewGeneration == self.settingsPreviewGeneration,
|
|
let readerView = self.context.readerView else {
|
|
return
|
|
}
|
|
|
|
switch result {
|
|
case .success(let chapter):
|
|
let partialMap = self.makePartialPageMap(from: [chapter])
|
|
self.context.bookPageMap = partialMap
|
|
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
|
|
readerView.reloadData()
|
|
|
|
if let targetPage = self.settingsPreviewTargetPage(
|
|
in: chapter,
|
|
for: previewAnchor
|
|
) {
|
|
readerView.transitionToPage(pageNum: targetPage, animated: false)
|
|
return
|
|
}
|
|
|
|
if let previewLocation,
|
|
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
|
|
return
|
|
}
|
|
readerView.transitionToPage(pageNum: 0, animated: false)
|
|
|
|
case .failure(let error):
|
|
RDEPUBBackgroundTrace.log(
|
|
"Runtime",
|
|
"repaginateCurrentChapterOnly FAILED spine=\(currentSpineIndex) generation=\(previewGeneration) error=\(error)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func settingsPreviewTargetPage(
|
|
in chapter: RDEPUBRuntimeChapter,
|
|
for anchor: SettingsPreviewAnchor?
|
|
) -> Int? {
|
|
guard let anchor,
|
|
anchor.spineIndex == chapter.spineIndex,
|
|
anchor.href == chapter.href,
|
|
!chapter.pages.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if let exactPage = chapter.pages.first(where: { page in
|
|
let lowerBound = page.contentRange.location
|
|
let upperBound = page.contentRange.location + page.contentRange.length
|
|
if page.contentRange.length == 0 {
|
|
return anchor.offset == lowerBound
|
|
}
|
|
return anchor.offset >= lowerBound && anchor.offset < upperBound
|
|
}) {
|
|
return exactPage.pageIndexInChapter
|
|
}
|
|
|
|
if let nextPage = chapter.pages.first(where: { page in
|
|
page.contentRange.location > anchor.offset
|
|
}) {
|
|
return nextPage.pageIndexInChapter
|
|
}
|
|
|
|
return max(chapter.pages.count - 1, 0)
|
|
}
|
|
|
|
func settingsPanelWillAppear() {
|
|
pendingSettingsPreviewWorkItem?.cancel()
|
|
pendingSettingsPreviewWorkItem = nil
|
|
isSettingsPanelOpen = true
|
|
needsFullRepaginationAfterSettingsClose = false
|
|
settingsPreviewGeneration += 1
|
|
}
|
|
|
|
func settingsPanelDidDisappear() {
|
|
pendingSettingsPreviewWorkItem?.cancel()
|
|
pendingSettingsPreviewWorkItem = nil
|
|
isSettingsPanelOpen = false
|
|
settingsPreviewGeneration += 1
|
|
|
|
if needsFullRepaginationAfterSettingsClose {
|
|
needsFullRepaginationAfterSettingsClose = false
|
|
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
|
|
paginationCoordinator.repaginatePreservingCurrentLocation()
|
|
}
|
|
}
|
|
|
|
func refreshVisibleContentPreservingLocation() {
|
|
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
|
}
|
|
|
|
func rebuildExternalTextBook() {
|
|
paginationCoordinator.rebuildExternalTextBook()
|
|
}
|
|
|
|
@discardableResult
|
|
func restoreReadingLocation(
|
|
_ location: RDEPUBLocation,
|
|
animated: Bool = false,
|
|
targetHighlightRangeInfo: String? = nil
|
|
) -> Bool {
|
|
locationCoordinator.restoreReadingLocation(
|
|
location,
|
|
animated: animated,
|
|
targetHighlightRangeInfo: targetHighlightRangeInfo
|
|
)
|
|
}
|
|
|
|
func currentVisibleLocation() -> RDEPUBLocation? {
|
|
locationCoordinator.currentVisibleLocation()
|
|
}
|
|
|
|
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
|
viewportMonitor.currentViewportSignature()
|
|
}
|
|
|
|
func handleViewportChangeIfNeeded(
|
|
reason: RDEPUBViewportChangeReason,
|
|
viewportSignature: RDEPUBViewportSignature? = nil
|
|
) {
|
|
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
|
}
|
|
|
|
@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
|
|
}
|
|
|
|
@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
|
|
)
|
|
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")"
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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() {
|
|
paginationCoordinator.cancelActiveMetadataParseWork()
|
|
chapterRuntimeStore.invalidateAllForSettingsChange()
|
|
context.bookPageMap = nil
|
|
context.pendingFullPageMap = nil
|
|
jumpSessionManager.clearSession()
|
|
backgroundPriorityManager.reset()
|
|
backgroundCoverageStore.clearAll()
|
|
}
|
|
|
|
func handleMemoryWarning() {
|
|
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
|
.flatMap { context.normalizedSpineIndex(for: $0) }
|
|
let activeWindowIndices: Set<Int> = if let currentSpineIndex {
|
|
[currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
|
|
} else {
|
|
[]
|
|
}
|
|
let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
|
|
|
|
backgroundCoverageStore.handleMemoryWarning(
|
|
activeWindowSpineIndices: activeWindowIndices,
|
|
protectedSpineIndices: protectedIndices
|
|
)
|
|
}
|
|
|
|
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 {
|
|
builder.add(
|
|
spineIndex: chapter.spineIndex,
|
|
href: chapter.href,
|
|
title: chapter.title,
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
|
)
|
|
}
|
|
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 {
|
|
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)
|
|
}
|
|
}
|