ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift
shenlei 22e7e44220 feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 18:50:07 +09:00

323 lines
11 KiB
Swift

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..<pageCount {
pages.append(
EPUBPage(
spineIndex: spineIndex,
chapterIndex: chapterIndex,
pageIndexInChapter: pageIndex,
totalPagesInChapter: pageCount,
chapterTitle: title,
fixedSpread: nil
)
)
}
}
return (pages, chapters)
}
}