// 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..