- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
397 lines
15 KiB
Swift
397 lines
15 KiB
Swift
// RDEPUBReadingSession.swift
|
||
// EPUB 阅读会话(状态机)
|
||
// 管理阅读器的运行时状态:导航状态(initializing/loading/idle/jumping/moving/repaginating)、
|
||
// 活跃页面列表、待应用的分页快照、挂起的导航请求、当前视口和阅读上下文。
|
||
// 提供分页快照生成、位置计算、导航队列、阅读上下文更新等核心业务方法。
|
||
|
||
import Foundation
|
||
|
||
/// EPUB 阅读会话,管理导航状态、页面列表和阅读进度
|
||
/// 状态机:initializing → loading → idle ↔ jumping/moving/repaginating
|
||
public final class RDEPUBReadingSession {
|
||
/// 分页快照类型别名(页面数组 + 章节信息数组)
|
||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||
|
||
/// 关联的出版物
|
||
public let publication: RDEPUBPublication
|
||
|
||
/// 当前导航状态
|
||
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
|
||
/// 当前生效的页面列表
|
||
public private(set) var activePages: [EPUBPage] = []
|
||
/// 当前生效的章节信息列表
|
||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
||
/// 暂存的待应用页面列表(分页计算完成后暂存,等状态允许时才生效)
|
||
public private(set) var stagedPages: [EPUBPage]?
|
||
/// 暂存的待应用章节信息列表
|
||
public private(set) var stagedChapters: [EPUBChapterInfo]?
|
||
/// 暂存快照对应的恢复位置
|
||
public private(set) var stagedRestoreLocation: RDEPUBLocation?
|
||
/// 挂起的导航目标位置(用于 fragment 精确定位)
|
||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||
/// 挂起的导航目标页码
|
||
public private(set) var pendingNavigationPageNum: Int?
|
||
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||
/// 当前视口信息
|
||
public private(set) var currentViewport: RDEPUBViewport?
|
||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
||
|
||
public init(publication: RDEPUBPublication) {
|
||
self.publication = publication
|
||
}
|
||
|
||
/// 资源解析器快捷访问
|
||
public var resourceResolver: RDEPUBResourceResolver {
|
||
publication.resourceResolver
|
||
}
|
||
|
||
/// 状态机转换
|
||
public func transition(to state: RDEPUBNavigatorState) {
|
||
navigatorState = state
|
||
}
|
||
|
||
/// 重置所有运行时状态(回到 initializing)
|
||
public func resetRuntimeState() {
|
||
navigatorState = .initializing
|
||
activePages = []
|
||
activeChapters = []
|
||
clearPendingNavigation()
|
||
clearStagedSnapshot()
|
||
currentViewport = nil
|
||
currentReadingContext = nil
|
||
}
|
||
|
||
/// 设置当前生效的分页快照
|
||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
||
activePages = snapshot.pages
|
||
activeChapters = snapshot.chapters
|
||
}
|
||
|
||
/// 暂存分页快照(待状态允许时才生效)
|
||
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
|
||
stagedPages = snapshot.pages
|
||
stagedChapters = snapshot.chapters
|
||
stagedRestoreLocation = restoreLocation
|
||
}
|
||
|
||
/// 获取暂存的分页快照
|
||
public func stagedSnapshot() -> PaginationSnapshot? {
|
||
guard let stagedPages, let stagedChapters else {
|
||
return nil
|
||
}
|
||
return (stagedPages, stagedChapters)
|
||
}
|
||
|
||
/// 如果状态稳定且有暂存快照,则消费并返回(原子操作)
|
||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||
guard navigatorState.isStableForSnapshotApplication,
|
||
let stagedPages,
|
||
let stagedChapters else {
|
||
return nil
|
||
}
|
||
let restoreLocation = stagedRestoreLocation
|
||
clearStagedSnapshot()
|
||
return ((stagedPages, stagedChapters), restoreLocation)
|
||
}
|
||
|
||
/// 清除暂存快照
|
||
public func clearStagedSnapshot() {
|
||
stagedPages = nil
|
||
stagedChapters = nil
|
||
stagedRestoreLocation = nil
|
||
}
|
||
|
||
/// 清除挂起的导航请求
|
||
public func clearPendingNavigation() {
|
||
pendingNavigationLocation = nil
|
||
pendingNavigationPageNum = nil
|
||
pendingNavigationHighlightRangeInfo = nil
|
||
}
|
||
|
||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||
guard pendingNavigationPageNum == pageNumber,
|
||
let pendingNavigationLocation else {
|
||
return nil
|
||
}
|
||
guard let spineIndex else {
|
||
return pendingNavigationLocation
|
||
}
|
||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||
return nil
|
||
}
|
||
return pendingNavigationLocation
|
||
}
|
||
|
||
/// 查询指定页是否存在待消费的高亮 Range 信息。
|
||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||
guard pendingNavigationPageNum == pageNumber,
|
||
let pendingNavigationHighlightRangeInfo,
|
||
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
||
return nil
|
||
}
|
||
guard let pendingNavigationLocation else {
|
||
return pendingNavigationHighlightRangeInfo
|
||
}
|
||
guard let spineIndex else {
|
||
return pendingNavigationHighlightRangeInfo
|
||
}
|
||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||
return nil
|
||
}
|
||
return pendingNavigationHighlightRangeInfo
|
||
}
|
||
|
||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||
if let fixedSpread = page.fixedSpread {
|
||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||
}
|
||
return page.spineIndex == spineIndex
|
||
}
|
||
|
||
/// 根据页面信息生成回退位置(用于无精确位置时的兜底)
|
||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||
if let fixedSpread = page.fixedSpread {
|
||
return RDEPUBLocation(
|
||
bookIdentifier: bookIdentifier,
|
||
href: fixedSpread.primaryResource.href,
|
||
progression: 0,
|
||
lastProgression: 1
|
||
)
|
||
}
|
||
|
||
guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||
return nil
|
||
}
|
||
|
||
let progression: Double
|
||
if page.totalPagesInChapter <= 1 {
|
||
progression = 0
|
||
} else {
|
||
progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1)
|
||
}
|
||
|
||
return RDEPUBLocation(
|
||
bookIdentifier: bookIdentifier,
|
||
href: href,
|
||
progression: progression,
|
||
lastProgression: progression
|
||
)
|
||
}
|
||
|
||
/// 获取当前可见页面对应的位置
|
||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||
guard currentPageNumber > 0,
|
||
activePages.indices.contains(currentPageNumber - 1) else {
|
||
return nil
|
||
}
|
||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||
}
|
||
|
||
/// 根据位置获取初始 spine 索引
|
||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||
guard let location,
|
||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||
let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else {
|
||
return 0
|
||
}
|
||
return spineIndex
|
||
}
|
||
|
||
/// 根据位置计算对应的页面索引(支持固定版式和重排模式)
|
||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||
return nil
|
||
}
|
||
guard let targetHref = resourceResolver.normalizedHref(location.href) else {
|
||
return nil
|
||
}
|
||
|
||
if publication.layout == .fixed {
|
||
return activePages.firstIndex(where: { page in
|
||
if let fixedSpread = page.fixedSpread {
|
||
return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) })
|
||
}
|
||
guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||
return false
|
||
}
|
||
return self.resourceResolver.normalizedHref(href) == targetHref
|
||
})
|
||
}
|
||
|
||
guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else {
|
||
return nil
|
||
}
|
||
|
||
let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex }
|
||
guard !candidates.isEmpty else {
|
||
return nil
|
||
}
|
||
if candidates.count == 1 {
|
||
return candidates[0].offset
|
||
}
|
||
|
||
let navigationProgression = location.navigationProgression
|
||
let localIndex = min(
|
||
candidates.count - 1,
|
||
max(0, Int(round(navigationProgression * Double(candidates.count - 1))))
|
||
)
|
||
return candidates[localIndex].offset
|
||
}
|
||
|
||
/// 排队导航到指定位置:规范化位置、查找页面索引、设置挂起导航、切换到 jumping 状态
|
||
public func queueNavigation(
|
||
to location: RDEPUBLocation,
|
||
relativeToSpineIndex spineIndex: Int? = nil,
|
||
bookIdentifier: String?,
|
||
targetHighlightRangeInfo: String? = nil
|
||
) -> Int? {
|
||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: spineIndex,
|
||
bookIdentifier: bookIdentifier
|
||
), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else {
|
||
return nil
|
||
}
|
||
|
||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||
let shouldKeepPendingNavigation =
|
||
normalizedLocation.fragment != nil ||
|
||
normalizedLocation.rangeAnchor != nil ||
|
||
hasTargetHighlightRangeInfo
|
||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||
transition(to: .jumping)
|
||
return pageIndex + 1
|
||
}
|
||
|
||
/// 更新阅读上下文:规范化位置、设置视口和阅读上下文、清除挂起导航、回到 idle 状态
|
||
public func updateReadingContext(
|
||
pageNumber: Int,
|
||
location: RDEPUBLocation,
|
||
spineIndex: Int,
|
||
chapterIndex: Int?,
|
||
bookIdentifier: String?
|
||
) {
|
||
let normalizedLocation = resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: spineIndex,
|
||
bookIdentifier: bookIdentifier
|
||
) ?? RDEPUBLocation(
|
||
bookIdentifier: bookIdentifier,
|
||
href: location.href,
|
||
progression: location.progression,
|
||
lastProgression: location.lastProgression,
|
||
fragment: location.fragment
|
||
)
|
||
|
||
let resource = RDEPUBViewportResource(
|
||
href: normalizedLocation.href,
|
||
spineIndex: spineIndex,
|
||
progression: normalizedLocation.progression,
|
||
lastProgression: normalizedLocation.lastProgression,
|
||
fragment: normalizedLocation.fragment
|
||
)
|
||
let viewport = RDEPUBViewport(
|
||
resources: [resource],
|
||
visiblePageNumber: pageNumber,
|
||
chapterIndex: chapterIndex,
|
||
isFixedLayout: publication.layout == .fixed
|
||
)
|
||
currentViewport = viewport
|
||
currentReadingContext = RDEPUBReadingContext(
|
||
location: normalizedLocation,
|
||
viewport: viewport,
|
||
pageNumber: pageNumber,
|
||
chapterIndex: chapterIndex
|
||
)
|
||
|
||
if pendingNavigationPageNum == pageNumber {
|
||
clearPendingNavigation()
|
||
}
|
||
if navigatorState == .jumping || navigatorState == .moving {
|
||
transition(to: .idle)
|
||
}
|
||
}
|
||
|
||
/// 获取当前阅读位置(优先从阅读上下文获取,回退到可见位置)
|
||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||
}
|
||
|
||
/// 根据分页计数和偏好设置生成分页快照
|
||
/// 固定版式按 spread 分页,重排模式按 spine 索引逐章分页
|
||
public func makePaginationSnapshot(
|
||
pageCounts: [Int],
|
||
preferences: RDEPUBPreferences,
|
||
layoutContext: RDEPUBNavigatorLayoutContext
|
||
) -> PaginationSnapshot {
|
||
if publication.layout == .fixed {
|
||
let spreads = publication.makeFixedSpreads(
|
||
preferences: preferences,
|
||
viewportSize: layoutContext.viewportSize
|
||
)
|
||
|
||
let chapters = spreads.enumerated().map { spreadIndex, spread in
|
||
EPUBChapterInfo(
|
||
spineIndex: spread.primaryResource.spineIndex,
|
||
title: spread.primaryResource.title,
|
||
pageCount: 1
|
||
)
|
||
}
|
||
|
||
let pages = spreads.enumerated().map { spreadIndex, spread in
|
||
EPUBPage(
|
||
spineIndex: spread.primaryResource.spineIndex,
|
||
chapterIndex: spreadIndex,
|
||
pageIndexInChapter: 0,
|
||
totalPagesInChapter: 1,
|
||
chapterTitle: spread.primaryResource.title,
|
||
fixedSpread: spread
|
||
)
|
||
}
|
||
|
||
return (pages, chapters)
|
||
}
|
||
|
||
var pages: [EPUBPage] = []
|
||
var chapters: [EPUBChapterInfo] = []
|
||
|
||
for (spineIndex, pageCount) in pageCounts.enumerated() {
|
||
guard pageCount > 0, spineIndex < publication.spine.count else { continue }
|
||
|
||
let chapterIndex = chapters.count
|
||
let title = publication.spine[spineIndex].title
|
||
|
||
chapters.append(
|
||
EPUBChapterInfo(
|
||
spineIndex: spineIndex,
|
||
title: title,
|
||
pageCount: pageCount
|
||
)
|
||
)
|
||
|
||
for pageIndex in 0..<pageCount {
|
||
pages.append(
|
||
EPUBPage(
|
||
spineIndex: spineIndex,
|
||
chapterIndex: chapterIndex,
|
||
pageIndexInChapter: pageIndex,
|
||
totalPagesInChapter: pageCount,
|
||
chapterTitle: title,
|
||
fixedSpread: nil
|
||
)
|
||
)
|
||
}
|
||
}
|
||
|
||
return (pages, chapters)
|
||
}
|
||
}
|