import Foundation public final class RDEPUBReadingSession { public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo]) public let publication: RDEPUBPublication public private(set) var activePages: [EPUBPage] = [] public private(set) var activeChapters: [EPUBChapterInfo] = [] public private(set) var pendingNavigationLocation: RDEPUBLocation? public private(set) var pendingNavigationPageNum: Int? 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 resetRuntimeState() { activePages = [] activeChapters = [] clearPendingNavigation() currentViewport = nil currentReadingContext = nil } public func setActiveSnapshot(_ snapshot: PaginationSnapshot) { activePages = snapshot.pages activeChapters = snapshot.chapters } public func clearPendingNavigation() { pendingNavigationLocation = nil pendingNavigationPageNum = nil pendingNavigationHighlightRangeInfo = nil } 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 } 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 } 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) } 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 } 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 return pageIndex + 1 } 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, rangeAnchor: location.rangeAnchor, cfi: location.cfi, lastCFI: location.lastCFI, rangeCFI: location.rangeCFI ) 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() } } public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? { currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier) } 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..