- 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>
323 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|