refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBBookPageMapEntry {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let absolutePageStart: Int
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
}
|
||||
|
||||
struct RDEPUBBookPageMap {
|
||||
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
|
||||
private let indexBySpine: [Int: Int]
|
||||
|
||||
let totalPages: Int
|
||||
|
||||
init(entries: [RDEPUBBookPageMapEntry]) {
|
||||
self.entries = entries
|
||||
var mapping: [Int: Int] = [:]
|
||||
for (i, entry) in entries.enumerated() {
|
||||
mapping[entry.spineIndex] = i
|
||||
}
|
||||
self.indexBySpine = mapping
|
||||
self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
|
||||
}
|
||||
|
||||
static let empty = RDEPUBBookPageMap(entries: [])
|
||||
|
||||
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
let entry = entries[idx]
|
||||
guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
|
||||
return entry.absolutePageStart + localPageIndex
|
||||
}
|
||||
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
|
||||
|
||||
var lo = 0, hi = entries.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
if entries[mid].absolutePageStart <= absolutePage {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
guard lo > 0 else { return nil }
|
||||
return entries[lo - 1].spineIndex
|
||||
}
|
||||
|
||||
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard let si = spineIndex(forAbsolutePage: absolutePage),
|
||||
let idx = indexBySpine[si] else { return nil }
|
||||
let entry = entries[idx]
|
||||
let local = absolutePage - entry.absolutePageStart
|
||||
guard local >= 0, local < entry.pageCount else { return nil }
|
||||
return local
|
||||
}
|
||||
|
||||
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
return entries[idx]
|
||||
}
|
||||
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
|
||||
indexBySpine[spineIndex]
|
||||
}
|
||||
|
||||
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
|
||||
entry(forSpineIndex: spineIndex)?.pageCount
|
||||
}
|
||||
|
||||
var totalChapters: Int { entries.count }
|
||||
|
||||
struct Builder {
|
||||
|
||||
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
|
||||
|
||||
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
|
||||
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
|
||||
}
|
||||
|
||||
func build() -> RDEPUBBookPageMap {
|
||||
|
||||
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
|
||||
var entries: [RDEPUBBookPageMapEntry] = []
|
||||
var absolutePageStart = 0
|
||||
for item in sorted {
|
||||
entries.append(RDEPUBBookPageMapEntry(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: item.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: item.fragmentOffsets
|
||||
))
|
||||
absolutePageStart += item.pageCount
|
||||
}
|
||||
return RDEPUBBookPageMap(entries: entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
|
||||
let bookID: String
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterDataCache {
|
||||
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
|
||||
private let maxEntryCount: Int
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
init(maxEntryCount: Int = 30) {
|
||||
self.maxEntryCount = maxEntryCount
|
||||
}
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let chapter = storage[spineIndex] else {
|
||||
return nil
|
||||
}
|
||||
touchLocked(spineIndex)
|
||||
return chapter
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let newValue {
|
||||
storage[spineIndex] = newValue
|
||||
touchLocked(spineIndex)
|
||||
// Evict oldest entries if over limit
|
||||
evictIfNeededLocked()
|
||||
} else {
|
||||
storage.removeValue(forKey: spineIndex)
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var storedSpineIndices: [Int] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return Array(storage.keys)
|
||||
}
|
||||
|
||||
func remove(spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeValue(forKey: spineIndex)
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
accessOrder.removeAll()
|
||||
}
|
||||
|
||||
/// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
|
||||
/// Must be called while holding lock.
|
||||
private func evictIfNeededLocked() {
|
||||
while storage.count > maxEntryCount, let oldest = accessOrder.first {
|
||||
storage.removeValue(forKey: oldest)
|
||||
accessOrder.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an entry as most recently used.
|
||||
/// Must be called while holding lock.
|
||||
private func touchLocked(_ spineIndex: Int) {
|
||||
accessOrder.removeAll { $0 == spineIndex }
|
||||
accessOrder.append(spineIndex)
|
||||
}
|
||||
}
|
||||
+838
@@ -0,0 +1,838 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
|
||||
private struct PendingLoad {
|
||||
var priority: LoadPriority
|
||||
var completions: [LoadCompletion]
|
||||
}
|
||||
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
private let pendingLoadsLock = NSLock()
|
||||
|
||||
private var pendingLoads: [Int: PendingLoad] = [:]
|
||||
|
||||
var onDeferredCFIMapReady: ((Int) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
enum LoadPriority {
|
||||
|
||||
case navigation
|
||||
|
||||
case preview
|
||||
|
||||
case prefetch
|
||||
}
|
||||
|
||||
private enum LoadRegistrationResult {
|
||||
case created
|
||||
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
|
||||
}
|
||||
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
let layoutSnapshot = context?.makeLayoutSnapshot()
|
||||
loadChapterWithSnapshot(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: priority,
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func loadChapterWithSnapshot(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot?,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
|
||||
)
|
||||
if let context {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let completionOnMain: LoadCompletion = { result in
|
||||
DispatchQueue.main.async {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: priority,
|
||||
completion: completionOnMain
|
||||
)
|
||||
|
||||
switch registration {
|
||||
case .joined(let existingPriority, let effectivePriority):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
|
||||
)
|
||||
return
|
||||
case .created:
|
||||
break
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
_ = store.beginPendingChapterLoad(for: spineIndex)
|
||||
|
||||
store.chapterLoadQueue.async { [weak self] in
|
||||
guard let self, let context = self.context else {
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
|
||||
return
|
||||
}
|
||||
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
} else {
|
||||
diskSummary = nil
|
||||
}
|
||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
let pageRangeSource: String
|
||||
if precomputedPageRanges != nil {
|
||||
pageRangeSource = "HIT(memoryPageCount)"
|
||||
} else if diskPageRanges != nil {
|
||||
pageRangeSource = "HIT(diskSummary)"
|
||||
} else {
|
||||
pageRangeSource = "MISS(fullRender)"
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
|
||||
)
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
do {
|
||||
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary,
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pc = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
|
||||
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
switch effectivePriority {
|
||||
case .navigation:
|
||||
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .preview:
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .prefetch:
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
|
||||
)
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let context else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let store else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if store.hasPendingChapterLoad(for: spineIndex) {
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: .navigation
|
||||
) { pendingResult in
|
||||
result = pendingResult
|
||||
semaphore.signal()
|
||||
}
|
||||
if case .joined(let existingPriority, let effectivePriority) = registration {
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
store.chapterLoadQueue.async {
|
||||
do {
|
||||
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
} else {
|
||||
diskSummary = nil
|
||||
}
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary,
|
||||
context: context,
|
||||
layoutSnapshot: snapshot
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pageCount, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
return chapter
|
||||
}
|
||||
result = .success(chapter)
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
private func registerPendingLoad(
|
||||
spineIndex: Int,
|
||||
priority: LoadPriority,
|
||||
completion: @escaping LoadCompletion
|
||||
) -> LoadRegistrationResult {
|
||||
pendingLoadsLock.lock()
|
||||
defer { pendingLoadsLock.unlock() }
|
||||
|
||||
if var pending = pendingLoads[spineIndex] {
|
||||
let existingPriority = pending.priority
|
||||
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
|
||||
pending.completions.append(completion)
|
||||
pendingLoads[spineIndex] = pending
|
||||
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
|
||||
}
|
||||
|
||||
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
|
||||
return .created
|
||||
}
|
||||
|
||||
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
|
||||
pendingLoadsLock.lock()
|
||||
let priority = pendingLoads[spineIndex]?.priority
|
||||
pendingLoadsLock.unlock()
|
||||
return priority
|
||||
}
|
||||
|
||||
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
|
||||
pendingLoadsLock.lock()
|
||||
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
|
||||
pendingLoadsLock.unlock()
|
||||
|
||||
guard !completions.isEmpty else { return }
|
||||
|
||||
completions.forEach { $0(result) }
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
let pageSize: CGSize
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
if let snapshot = layoutSnapshot {
|
||||
pageSize = snapshot.pageSize
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
} else {
|
||||
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: pageRanges,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
return try assembleRuntimeChapter(
|
||||
from: result.chapter,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig,
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
let rawHTML = try requireHTMLString(parser, href: href)
|
||||
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
let renderer = context.resolvedTextRenderer()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
|
||||
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
|
||||
let effectivePageRanges: [NSRange]
|
||||
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
|
||||
|
||||
if sanitizedCachedRanges.count == pageRanges.count {
|
||||
effectivePageRanges = sanitizedCachedRanges
|
||||
metadataSource = diskSummary?.pageMetadataList
|
||||
} else {
|
||||
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
|
||||
metadataSource = nil
|
||||
}
|
||||
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: effectivePageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap,
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: effectivePageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
private func buildPagesFromRanges(
|
||||
pageRanges: [NSRange],
|
||||
typesetString: NSAttributedString,
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
title: String,
|
||||
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
|
||||
) -> [RDEPUBTextPage] {
|
||||
let totalPageCount = pageRanges.count
|
||||
return pageRanges.enumerated().map { (pageIndex, range) in
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
isLastPage: pageIndex == totalPageCount - 1
|
||||
)
|
||||
}
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: -1,
|
||||
chapterIndex: 0,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
chapterTitle: title,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: totalPageCount,
|
||||
chapterContent: typesetString,
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + range.length - 1,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
|
||||
guard contentLength > 0 else { return [] }
|
||||
|
||||
return pageRanges.compactMap { range in
|
||||
guard range.location >= 0, range.location < contentLength else {
|
||||
return nil
|
||||
}
|
||||
let maxLength = contentLength - range.location
|
||||
let clampedLength = min(max(range.length, 0), maxLength)
|
||||
guard clampedLength > 0 else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: range.location, length: clampedLength)
|
||||
}
|
||||
}
|
||||
|
||||
private func inferPageMetadata(
|
||||
from string: NSAttributedString,
|
||||
range: NSRange,
|
||||
isLastPage: Bool
|
||||
) -> RDEPUBTextPageMetadata {
|
||||
var attachmentRanges: [NSRange] = []
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
|
||||
var blockKinds: [RDEPUBTextBlockKind] = []
|
||||
var semanticHints: [RDEPUBTextSemanticHint] = []
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
|
||||
var trailingFragmentID: String? = nil
|
||||
|
||||
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
|
||||
attachmentRanges.append(attrRange)
|
||||
attachmentKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!blockKinds.contains(kind) {
|
||||
blockKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
for hint in hints where !semanticHints.contains(hint) {
|
||||
semanticHints.append(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!attachmentPlacements.contains(placement) {
|
||||
attachmentPlacements.append(placement)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
|
||||
if let fid = value as? String {
|
||||
trailingFragmentID = fid
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
return RDEPUBTextPageMetadata(
|
||||
breakReason: isLastPage ? .chapterEnd : .frameLimit,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges,
|
||||
attachmentKinds: attachmentKinds,
|
||||
blockKinds: blockKinds,
|
||||
semanticHints: semanticHints,
|
||||
attachmentPlacements: attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: []
|
||||
)
|
||||
}
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
|
||||
cfiMap: chapter.cfiMap,
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: chapter.attributedContent,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pages: chapter.pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
let lineHeightMultiple: CGFloat
|
||||
|
||||
if let snapshot = layoutSnapshot {
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
} else {
|
||||
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
let pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
}
|
||||
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for chapter: RDEPUBRuntimeChapter,
|
||||
cacheKey: RDEPUBChapterCacheKey,
|
||||
store: RDEPUBChapterRuntimeStore
|
||||
) {
|
||||
guard chapter.chapterOffsetMap.cfiMap == nil,
|
||||
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let spineIndex = chapter.spineIndex
|
||||
let href = chapter.href
|
||||
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
|
||||
let chapterText = chapter.chapterOffsetMap.chapterText
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
|
||||
let cfiMap = self.makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText
|
||||
)
|
||||
chapter.updateCFIMap(cfiMap)
|
||||
self.summaryDiskCache?.write(
|
||||
summary: self.makeSummary(
|
||||
for: chapter.pages,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
|
||||
offsetMap: chapter.chapterOffsetMap,
|
||||
cacheKey: cacheKey
|
||||
),
|
||||
for: cacheKey
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.onDeferredCFIMapReady?(spineIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSummary(
|
||||
for pages: [RDEPUBTextPage],
|
||||
fragmentOffsets: [String: Int],
|
||||
offsetMap: RDEPUBChapterOffsetMap,
|
||||
cacheKey: RDEPUBChapterCacheKey
|
||||
) -> RDEPUBChapterSummary {
|
||||
RDEPUBChapterSummary(
|
||||
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: pages.count,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: pages.map { .from($0.metadata) }
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
let href = publication.spine[spineIndex].href
|
||||
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||||
return html.rd_sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
guard let html = parser.htmlString(forRelativePath: href) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func makeCFIMap(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
fragmentOffsets: [String: Int],
|
||||
rawHTML: String?,
|
||||
chapterText: String
|
||||
) -> RDEPUBCFIMap {
|
||||
if let rawHTML {
|
||||
return RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
let domPaths: [String: RDEPUBCFIPath] = [:]
|
||||
let markers = fragmentOffsets
|
||||
.sorted { $0.value < $1.value }
|
||||
.map { fragmentID, offset in
|
||||
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
return RDEPUBCFIMarker(
|
||||
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
return RDEPUBCFIMap(
|
||||
href: href,
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
|
||||
fragmentPathMap: domPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDEPUBChapterLoader.LoadPriority {
|
||||
|
||||
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
|
||||
if lhs.rank >= rhs.rank {
|
||||
return lhs
|
||||
}
|
||||
return rhs
|
||||
}
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .prefetch:
|
||||
return 0
|
||||
case .preview:
|
||||
return 1
|
||||
case .navigation:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
|
||||
case missingParser
|
||||
|
||||
case emptyChapter(spineIndex: Int)
|
||||
|
||||
case emptyChapterHref(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingParser:
|
||||
return "章节加载失败:缺少解析上下文。"
|
||||
case .emptyChapter(let spineIndex):
|
||||
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
|
||||
case .emptyChapterHref(let href):
|
||||
return "章节加载失败:未找到章节资源 \(href)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
|
||||
public var spineIndex: Int
|
||||
|
||||
public var chapterOffset: Int
|
||||
|
||||
public var fragmentID: String?
|
||||
|
||||
public var progressionInChapter: Double?
|
||||
|
||||
public var schemaVersion: Int
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterOffset: Int,
|
||||
fragmentID: String? = nil,
|
||||
progressionInChapter: Double? = nil,
|
||||
schemaVersion: Int = 2
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self.progressionInChapter = progressionInChapter
|
||||
self.schemaVersion = schemaVersion
|
||||
}
|
||||
|
||||
var isFallbackEstimate: Bool { schemaVersion == 1 }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterOffsetMap {
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let pageStartOffsets: [Int]
|
||||
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
private var _cfiMap: RDEPUBCFIMap?
|
||||
|
||||
var cfiMap: RDEPUBCFIMap? {
|
||||
cfiMapLock.lock()
|
||||
defer { cfiMapLock.unlock() }
|
||||
return _cfiMap
|
||||
}
|
||||
|
||||
let chapterText: String?
|
||||
|
||||
init(
|
||||
fragmentOffsets: [String: Int],
|
||||
pageStartOffsets: [Int],
|
||||
pageEndOffsets: [Int],
|
||||
cfiMap: RDEPUBCFIMap?,
|
||||
chapterText: String?
|
||||
) {
|
||||
self.fragmentOffsets = fragmentOffsets
|
||||
self.pageStartOffsets = pageStartOffsets
|
||||
self.pageEndOffsets = pageEndOffsets
|
||||
self._cfiMap = cfiMap
|
||||
self.chapterText = chapterText
|
||||
}
|
||||
|
||||
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
|
||||
cfiMapLock.lock()
|
||||
_cfiMap = cfiMap
|
||||
cfiMapLock.unlock()
|
||||
}
|
||||
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
func chapterOffset(forCFI rawCFI: String?) -> Int? {
|
||||
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
|
||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
||||
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
|
||||
return RDEPUBCFIRecoveryEngine.recover(
|
||||
cfi: cfi,
|
||||
cfiMap: cfiMap,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
)?.chapterOffset
|
||||
}
|
||||
|
||||
func pageIndex(forChapterOffset offset: Int) -> Int? {
|
||||
for i in 0..<pageStartOffsets.count {
|
||||
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||
|
||||
private let pageCountCache = RDEPUBPageCountCache()
|
||||
|
||||
let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated)
|
||||
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
|
||||
// (verified by audit of all 7 access points). They are not protected by locks unlike
|
||||
// navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
|
||||
// access remains main-thread-only. Do NOT access from chapterLoadQueue.
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
private var pendingNavigationTarget: Int?
|
||||
|
||||
private let navigationLock = NSLock()
|
||||
|
||||
private var pendingPrefetchTargets: Set<Int> = []
|
||||
|
||||
private let prefetchLock = NSLock()
|
||||
|
||||
private(set) var isBuilding: Bool = false
|
||||
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
private var buildingCFIMapSpineIndices: Set<Int> = []
|
||||
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
private var pendingChapterLoadSpineIndices: Set<Int> = []
|
||||
|
||||
private let pendingChapterLoadLock = NSLock()
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
imageCache.totalCostLimit = 104_857_600 // 100 MB
|
||||
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
func assertNotOnChapterLoadQueue() {
|
||||
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||
}
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
return chapterDataCache[spineIndex]
|
||||
}
|
||||
|
||||
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
return pageCountCache[key]
|
||||
}
|
||||
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||
chapterDataCache[chapter.spineIndex] = chapter
|
||||
RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
|
||||
}
|
||||
|
||||
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
|
||||
pageCountCache[key] = pc
|
||||
}
|
||||
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||
currentSpineIndex = spineIndex
|
||||
let radius = max(0, windowRadius)
|
||||
let lowerBound = max(0, spineIndex - radius)
|
||||
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
|
||||
guard lowerBound <= upperBound else {
|
||||
windowSpineIndices = [spineIndex]
|
||||
return
|
||||
}
|
||||
windowSpineIndices = Array(lowerBound...upperBound)
|
||||
}
|
||||
|
||||
func evictableSpineIndices() -> [Int] {
|
||||
let windowSet = Set(windowSpineIndices)
|
||||
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
|
||||
}
|
||||
|
||||
func evict(spineIndex: Int) {
|
||||
chapterDataCache.remove(spineIndex: spineIndex)
|
||||
pageCountCache.remove(forSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func evictAllExceptCurrent() {
|
||||
guard let current = currentSpineIndex else {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
return
|
||||
}
|
||||
let currentChapter = chapterDataCache[current]
|
||||
chapterDataCache.removeAll()
|
||||
if let ch = currentChapter {
|
||||
chapterDataCache[current] = ch
|
||||
}
|
||||
|
||||
pageCountCache.removeAll()
|
||||
}
|
||||
|
||||
func handleMemoryWarning() {
|
||||
evictAllExceptCurrent()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
|
||||
func setNavigationTarget(spineIndex: Int) {
|
||||
navigationLock.lock()
|
||||
pendingNavigationTarget = spineIndex
|
||||
navigationLock.unlock()
|
||||
}
|
||||
|
||||
func consumeNavigationTarget() -> Int? {
|
||||
navigationLock.lock()
|
||||
let target = pendingNavigationTarget
|
||||
pendingNavigationTarget = nil
|
||||
navigationLock.unlock()
|
||||
return target
|
||||
}
|
||||
|
||||
func addPrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.insert(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
func removePrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.remove(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
func clearPrefetchTargets() {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.removeAll()
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
|
||||
prefetchLock.lock()
|
||||
let has = pendingPrefetchTargets.contains(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
return has
|
||||
}
|
||||
|
||||
func markBuilding(_ building: Bool) {
|
||||
buildingLock.lock()
|
||||
isBuilding = building
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
|
||||
pendingChapterLoadLock.lock()
|
||||
defer { pendingChapterLoadLock.unlock() }
|
||||
return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
|
||||
}
|
||||
|
||||
func endPendingChapterLoad(for spineIndex: Int) {
|
||||
pendingChapterLoadLock.lock()
|
||||
pendingChapterLoadSpineIndices.remove(spineIndex)
|
||||
pendingChapterLoadLock.unlock()
|
||||
}
|
||||
|
||||
func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
|
||||
pendingChapterLoadLock.lock()
|
||||
let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
|
||||
pendingChapterLoadLock.unlock()
|
||||
return hasPendingLoad
|
||||
}
|
||||
|
||||
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
|
||||
cfiMapLock.lock()
|
||||
defer { cfiMapLock.unlock() }
|
||||
let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
|
||||
return inserted
|
||||
}
|
||||
|
||||
func endBuildingCFIMap(for spineIndex: Int) {
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.remove(spineIndex)
|
||||
cfiMapLock.unlock()
|
||||
}
|
||||
|
||||
func invalidateAllLayoutDependentContent() {
|
||||
RDEPUBMemoryProbe.log("layoutDependentContentInvalidateAll")
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
imageCache.removeAllObjects()
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.removeAll()
|
||||
cfiMapLock.unlock()
|
||||
pendingChapterLoadLock.lock()
|
||||
pendingChapterLoadSpineIndices.removeAll()
|
||||
pendingChapterLoadLock.unlock()
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility)
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
self.cacheDirectory = cacheDirectory
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.sync {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||||
summaries: [Int: RDEPUBChapterSummary],
|
||||
mapBuilder: RDEPUBBookPageMap.Builder
|
||||
) {
|
||||
var summaries: [Int: RDEPUBChapterSummary] = [:]
|
||||
var mapBuilder = RDEPUBBookPageMap.Builder()
|
||||
|
||||
for item in keys {
|
||||
if let summary = read(for: item.key) {
|
||||
summaries[item.spineIndex] = summary
|
||||
mapBuilder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: summary.pageCount,
|
||||
fragmentOffsets: summary.fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
return (summaries, mapBuilder)
|
||||
}
|
||||
|
||||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
for key in keys {
|
||||
if read(for: key) == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
removeFiles(matching: { _ in true })
|
||||
}
|
||||
|
||||
func removeAll(forBookID bookID: String) {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||||
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||||
}
|
||||
|
||||
func removeAll(forRenderSignature renderSignature: String) {
|
||||
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||||
removeFiles { $0.contains(renderPrefix) }
|
||||
}
|
||||
|
||||
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return (0, 0)
|
||||
}
|
||||
var count = 0
|
||||
var totalBytes: Int64 = 0
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
count += 1
|
||||
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalBytes += Int64(size)
|
||||
}
|
||||
}
|
||||
return (count, totalBytes)
|
||||
}
|
||||
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.rd_sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
do {
|
||||
let data = try JSONEncoder().encode(summary)
|
||||
try data.write(to: tmpURL)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
try? fileManager.removeItem(at: tmpURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFiles(matching predicate: (String) -> Bool) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.rd_sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBChapterSummary: Codable {
|
||||
|
||||
let pageRanges: [RangeData]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let schemaVersion: Int
|
||||
|
||||
let chapterContentHash: String
|
||||
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 17
|
||||
|
||||
struct RangeData: Codable {
|
||||
|
||||
let location: Int
|
||||
|
||||
let length: Int
|
||||
|
||||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||||
}
|
||||
|
||||
struct PageMetadataSummary: Codable {
|
||||
|
||||
let breakReason: String
|
||||
|
||||
let attachmentRanges: [RangeData]
|
||||
|
||||
let attachmentKinds: [String]
|
||||
|
||||
let blockKinds: [String]
|
||||
|
||||
let semanticHints: [String]
|
||||
|
||||
let attachmentPlacements: [String]
|
||||
|
||||
let trailingFragmentID: String?
|
||||
|
||||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||||
RDEPUBTextPageMetadata(
|
||||
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges.map { $0.nsRange },
|
||||
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
|
||||
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
|
||||
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
|
||||
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: []
|
||||
)
|
||||
}
|
||||
|
||||
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
|
||||
PageMetadataSummary(
|
||||
breakReason: metadata.breakReason.rawValue,
|
||||
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
|
||||
blockKinds: metadata.blockKinds.map { $0.rawValue },
|
||||
semanticHints: metadata.semanticHints.map { $0.rawValue },
|
||||
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
|
||||
trailingFragmentID: metadata.trailingFragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+679
@@ -0,0 +1,679 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private unowned let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private unowned let loader: RDEPUBChapterLoader
|
||||
|
||||
private unowned let presentationRuntime: RDEPUBPresentationRuntime
|
||||
|
||||
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
|
||||
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
|
||||
|
||||
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
||||
|
||||
private let refreshVisibleContentPreservingLocation: () -> Void
|
||||
|
||||
private let asyncLoadStateLock = NSLock()
|
||||
|
||||
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
|
||||
|
||||
private var isExtendingPartialBookPageMap = false
|
||||
|
||||
private let prepareRequestStateLock = NSLock()
|
||||
|
||||
private var pendingPreparePageNumbers: Set<Int> = []
|
||||
|
||||
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
||||
|
||||
private static let upcomingChapterLookaheadCount = 2
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
loader: RDEPUBChapterLoader,
|
||||
presentationRuntime: RDEPUBPresentationRuntime,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
|
||||
jumpSessionManager: RDEPUBJumpSessionManager,
|
||||
refreshVisibleContentPreservingLocation: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
self.presentationRuntime = presentationRuntime
|
||||
self.locationCoordinator = locationCoordinator
|
||||
self.backgroundPriorityManager = backgroundPriorityManager
|
||||
self.jumpSessionManager = jumpSessionManager
|
||||
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(
|
||||
forAbsolutePageNumber pageNumber: Int,
|
||||
allowSynchronousLoad: Bool = true,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let publication = context.publication else {
|
||||
return false
|
||||
}
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
let chapterReady = store.chapterData(for: spineIndex) != nil
|
||||
|
||||
if let debouncedResult = debouncedPrepareResult(
|
||||
pageNumber: pageNumber,
|
||||
spineIndex: spineIndex,
|
||||
chapterReady: chapterReady,
|
||||
allowSynchronousLoad: allowSynchronousLoad
|
||||
) {
|
||||
return debouncedResult
|
||||
}
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
if !chapterReady {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: spineIndex,
|
||||
triggerPageNumber: pageNumber,
|
||||
completion: completion
|
||||
)
|
||||
return false
|
||||
}
|
||||
do {
|
||||
_ = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
} catch {
|
||||
clearPendingPreparePageNumber(pageNumber)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
markPrepareResolved(pageNumber)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: Int,
|
||||
minimumTrailingPages: Int = 2,
|
||||
batchChapterCount: Int = 3
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = buildableSpineIndices(in: publication)
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
let loadedChaptersLock = NSLock()
|
||||
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
let group = DispatchGroup()
|
||||
|
||||
for spineIndex in spineIndicesToAppend {
|
||||
group.enter()
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { result in
|
||||
defer { group.leave() }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
loadedChaptersLock.lock()
|
||||
loadedChapters[spineIndex] = chapter
|
||||
loadedChaptersLock.unlock()
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.endPartialBookPageMapExtension() }
|
||||
// Use live position values rather than the stale captured values,
|
||||
// since the user may have turned several pages since the extension began.
|
||||
let livePageNumber = max(self.context.readerView?.currentPage ?? 0, 0) + 1
|
||||
let liveLocation = self.locationCoordinator.currentVisibleLocation()
|
||||
self.applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: livePageNumber,
|
||||
currentLocation: liveLocation,
|
||||
currentMap: currentMap,
|
||||
loadedChapters: loadedChapters
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if store.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
|
||||
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
|
||||
guard context.bookPageMap != nil,
|
||||
let publication = context.publication,
|
||||
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if context.pendingPageMapUpdates.contains(where: { update in
|
||||
update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
}) {
|
||||
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
|
||||
context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
let chapters = loadPartialWindowChapters(
|
||||
around: anchorPosition,
|
||||
in: buildableIndices,
|
||||
targetSpineIndex: targetSpineIndex,
|
||||
windowSize: normalizedWindowSize
|
||||
)
|
||||
guard !chapters.isEmpty else { return false }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = makePartialPageMap(from: chapters)
|
||||
context.bookPageMap = partialMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.removeAll()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.removeAll()
|
||||
recentPrepareTimestamps.removeAll()
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?,
|
||||
currentMap: RDEPUBBookPageMap,
|
||||
loadedChapters: [Int: RDEPUBRuntimeChapter]
|
||||
) {
|
||||
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
|
||||
guard let chapter = loadedChapters[spineIndex] else { return nil }
|
||||
return RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
guard !appendedEntries.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let combinedEntries = (currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||||
|
||||
var absolutePageStart = 0
|
||||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalized = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalized
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||||
presentationRuntime.queueExtendedPartialPageMap(
|
||||
newMap,
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: Int,
|
||||
triggerPageNumber: Int,
|
||||
completion: ((Bool) -> Void)?
|
||||
) {
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
|
||||
return
|
||||
}
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .preview
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
self.endAsynchronousChapterPreparation(for: spineIndex)
|
||||
switch result {
|
||||
case .success:
|
||||
self.markPrepareResolved(triggerPageNumber)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
case .failure(let error):
|
||||
self.clearPendingPreparePageNumber(triggerPageNumber)
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
|
||||
// otherwise the two policies evict/rebuild the same chapter in a loop.
|
||||
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
|
||||
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
|
||||
store.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(adjacentSpineIndex)
|
||||
loader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
|
||||
guard let publication = context.publication else { return [] }
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
|
||||
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
|
||||
}
|
||||
|
||||
private func maybePrefetchUpcomingChapters(
|
||||
aroundAbsolutePageNumber pageNumber: Int,
|
||||
in bookPageMap: RDEPUBBookPageMap,
|
||||
threshold: Int = 3,
|
||||
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
|
||||
) {
|
||||
guard let publication = context.publication else { return }
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = store.chapterData(for: spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let remainingPages = chapter.pages.count - localPageIndex - 1
|
||||
guard remainingPages <= threshold else { return }
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
|
||||
|
||||
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
|
||||
for targetSpineIndex in targets {
|
||||
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(targetSpineIndex)
|
||||
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
|
||||
minimumTrailingPages: Int = 2
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentPageNumber = max(readerView.currentPage + 1, 1)
|
||||
let trailingPages = currentMap.totalPages - currentPageNumber
|
||||
guard trailingPages <= minimumTrailingPages else { return }
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
var projectedTotalPages = currentMap.totalPages
|
||||
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = store.chapterData(for: spineIndex) else { break }
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
projectedTotalPages += chapter.pages.count
|
||||
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
|
||||
presentationRuntime.queueForwardAppendedPageMap(newMap)
|
||||
}
|
||||
|
||||
private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
|
||||
guard store.chapterData(for: spineIndex) == nil else { return false }
|
||||
guard !store.hasPrefetchTarget(spineIndex) else { return false }
|
||||
guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func debouncedPrepareResult(
|
||||
pageNumber: Int,
|
||||
spineIndex: Int,
|
||||
chapterReady: Bool,
|
||||
allowSynchronousLoad: Bool
|
||||
) -> Bool? {
|
||||
prepareRequestStateLock.lock()
|
||||
defer { prepareRequestStateLock.unlock() }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
|
||||
|
||||
if !allowSynchronousLoad && !chapterReady {
|
||||
let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
|
||||
if !inserted {
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
|
||||
now - lastTimestamp <= prepareRequestDebounceInterval {
|
||||
return chapterReady
|
||||
}
|
||||
|
||||
recentPrepareTimestamps[pageNumber] = now
|
||||
return nil
|
||||
}
|
||||
|
||||
private func markPrepareResolved(_ pageNumber: Int) {
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func clearPendingPreparePageNumber(_ pageNumber: Int) {
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
|
||||
guard let readerView = context.readerView,
|
||||
let bookPageMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
if readerView.isPageCurlTransitioning {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.refreshVisibleContentIfNeeded(
|
||||
afterPreparing: spineIndex,
|
||||
triggerPageNumber: triggerPageNumber
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
let visiblePageNumber = readerView.currentPage + 1
|
||||
if visiblePageNumber == triggerPageNumber {
|
||||
refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
guard visiblePageNumber > 0,
|
||||
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
return
|
||||
}
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
// Intentional synchronous path: called from TOC distant jumps where
|
||||
// the user expects immediate navigation. The loading indicator is shown
|
||||
// by the caller. Do NOT convert to async without UX consideration.
|
||||
private func loadPartialWindowChapters(
|
||||
around anchorPosition: Int,
|
||||
in buildableSpineIndices: [Int],
|
||||
targetSpineIndex: Int,
|
||||
windowSize: Int
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
|
||||
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
|
||||
let startIndex = max(0, upperBound - max(windowSize, 1))
|
||||
let window = Array(buildableSpineIndices[startIndex..<upperBound])
|
||||
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in window {
|
||||
do {
|
||||
let chapter = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == targetSpineIndex {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
builder.add(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
|
||||
}
|
||||
|
||||
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.remove(spineIndex)
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
|
||||
private func beginPartialBookPageMapExtension() -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
guard !isExtendingPartialBookPageMap else { return false }
|
||||
isExtendingPartialBookPageMap = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func endPartialBookPageMapExtension() {
|
||||
asyncLoadStateLock.lock()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBPageCountCache {
|
||||
|
||||
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[key]
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage[key] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
func remove(forSpineIndex spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage = storage.filter { $0.value.spineIndex != spineIndex }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBResolvedPage {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let chapter: RDEPUBRuntimeChapter
|
||||
|
||||
let chapterIndex: Int
|
||||
}
|
||||
|
||||
final class RDEPUBPageResolver {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = store.chapterData(for: spineIndex),
|
||||
chapter.pages.indices.contains(localPageIndex),
|
||||
let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var page = chapter.pages[localPageIndex]
|
||||
page.absolutePageIndex = absolutePageIndex
|
||||
page.chapterIndex = chapterIndex
|
||||
page.pageIndexInChapter = localPageIndex
|
||||
page.totalPagesInChapter = chapter.pages.count
|
||||
return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBRuntimeChapter {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
|
||||
var sourceAttributedString: NSAttributedString?
|
||||
|
||||
let typesetAttributedString: NSAttributedString
|
||||
|
||||
let layouter: RDEPUBTextLayouter
|
||||
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
let pages: [RDEPUBTextPage]
|
||||
|
||||
let chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
|
||||
init(
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
title: String,
|
||||
sourceAttributedString: NSAttributedString?,
|
||||
typesetAttributedString: NSAttributedString,
|
||||
layouter: RDEPUBTextLayouter,
|
||||
pageRanges: [NSRange],
|
||||
pages: [RDEPUBTextPage],
|
||||
chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.sourceAttributedString = sourceAttributedString
|
||||
self.typesetAttributedString = typesetAttributedString
|
||||
self.layouter = layouter
|
||||
self.pageRanges = pageRanges
|
||||
self.pages = pages
|
||||
self.chapterOffsetMap = chapterOffsetMap
|
||||
}
|
||||
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
|
||||
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
|
||||
chapterOffsetMap.updateCFIMap(cfiMap)
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRuntimePageCount {
|
||||
|
||||
let cacheKey: RDEPUBChapterCacheKey
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let renderSignature: String
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBBackgroundCoverageSegment {
|
||||
|
||||
let lowerSpineIndex: Int
|
||||
|
||||
let upperSpineIndex: Int
|
||||
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
|
||||
let resolvedSpineIndices: Set<Int>
|
||||
|
||||
let generatedAt: CFAbsoluteTime
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let estimatedMemoryBytes: Int
|
||||
|
||||
func contains(spineIndex: Int) -> Bool {
|
||||
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
|
||||
}
|
||||
|
||||
func distance(to spineIndex: Int) -> Int {
|
||||
if contains(spineIndex: spineIndex) { return 0 }
|
||||
return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBBackgroundCoverageStorePolicy {
|
||||
|
||||
let maxResidentSegments: Int
|
||||
|
||||
let maxChaptersPerSegment: Int
|
||||
|
||||
let memoryBudgetBytes: Int
|
||||
|
||||
static let `default` = RDEPUBBackgroundCoverageStorePolicy(
|
||||
maxResidentSegments: 8,
|
||||
maxChaptersPerSegment: 256,
|
||||
memoryBudgetBytes: 8 * 1024 * 1024
|
||||
)
|
||||
}
|
||||
|
||||
final class RDEPUBBackgroundCoverageStore {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let policy: RDEPUBBackgroundCoverageStorePolicy
|
||||
|
||||
private var segments: [RDEPUBBackgroundCoverageSegment] = []
|
||||
|
||||
private var currentMemoryBytes: Int = 0
|
||||
|
||||
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
|
||||
self.context = context
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
|
||||
|
||||
evictIfNeeded(forNewSegment: segment)
|
||||
|
||||
var merged = false
|
||||
for (index, existing) in segments.enumerated() {
|
||||
if canMerge(existing, segment) {
|
||||
if let mergedSegment = mergeSegments(existing, segment) {
|
||||
segments[index] = mergedSegment
|
||||
currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !merged {
|
||||
segments.append(segment)
|
||||
currentMemoryBytes += segment.estimatedMemoryBytes
|
||||
}
|
||||
|
||||
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
|
||||
}
|
||||
|
||||
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
|
||||
if let segment {
|
||||
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
||||
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { segment in
|
||||
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
|
||||
}
|
||||
if let segment {
|
||||
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
||||
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
|
||||
segments
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
segments.removeAll()
|
||||
currentMemoryBytes = 0
|
||||
lastAccessTime.removeAll()
|
||||
}
|
||||
|
||||
func clearColdSegments(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
) {
|
||||
let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices)
|
||||
segments.removeAll { segment in
|
||||
let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) })
|
||||
if isCold {
|
||||
currentMemoryBytes -= segment.estimatedMemoryBytes
|
||||
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
|
||||
}
|
||||
return isCold
|
||||
}
|
||||
}
|
||||
|
||||
func handleMemoryWarning(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
) {
|
||||
|
||||
clearColdSegments(
|
||||
activeWindowSpineIndices: activeWindowSpineIndices,
|
||||
protectedSpineIndices: protectedSpineIndices
|
||||
)
|
||||
|
||||
if currentMemoryBytes > policy.memoryBudgetBytes {
|
||||
|
||||
let sorted = segments.sorted { lhs, rhs in
|
||||
let lhsDistance = lhs.resolvedSpineIndices.map { idx in
|
||||
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
|
||||
}.min() ?? Int.max
|
||||
let rhsDistance = rhs.resolvedSpineIndices.map { idx in
|
||||
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
|
||||
}.min() ?? Int.max
|
||||
return lhsDistance > rhsDistance
|
||||
}
|
||||
|
||||
for segment in sorted {
|
||||
if currentMemoryBytes <= policy.memoryBudgetBytes { break }
|
||||
currentMemoryBytes -= segment.estimatedMemoryBytes
|
||||
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
|
||||
segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
|
||||
|
||||
while segments.count >= policy.maxResidentSegments {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
|
||||
while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
}
|
||||
|
||||
private func evictLeastRecentlyUsed() {
|
||||
guard !segments.isEmpty else { return }
|
||||
|
||||
var oldestTime = CFAbsoluteTimeGetCurrent()
|
||||
var oldestIndex = 0
|
||||
for (index, segment) in segments.enumerated() {
|
||||
let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0
|
||||
if accessTime < oldestTime {
|
||||
oldestTime = accessTime
|
||||
oldestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
let evicted = segments.remove(at: oldestIndex)
|
||||
currentMemoryBytes -= evicted.estimatedMemoryBytes
|
||||
lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex)
|
||||
}
|
||||
|
||||
private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
|
||||
|
||||
guard lhs.renderSignature == rhs.renderSignature else { return false }
|
||||
|
||||
let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
|
||||
rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
|
||||
return overlap
|
||||
}
|
||||
|
||||
private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
|
||||
let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
|
||||
let newChapterCount = newUpper - newLower + 1
|
||||
|
||||
if newChapterCount > policy.maxChaptersPerSegment {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
|
||||
|
||||
let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
|
||||
let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
|
||||
let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
|
||||
|
||||
return RDEPUBBackgroundCoverageSegment(
|
||||
lowerSpineIndex: newLower,
|
||||
upperSpineIndex: newUpper,
|
||||
pageMap: newPageMap,
|
||||
resolvedSpineIndices: newResolved,
|
||||
generatedAt: max(lhs.generatedAt, rhs.generatedAt),
|
||||
renderSignature: lhs.renderSignature,
|
||||
estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count)
|
||||
)
|
||||
}
|
||||
|
||||
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
|
||||
|
||||
for entry in older.entries {
|
||||
entriesBySpineIndex[entry.spineIndex] = entry
|
||||
}
|
||||
for entry in newer.entries {
|
||||
entriesBySpineIndex[entry.spineIndex] = entry
|
||||
}
|
||||
|
||||
for spineIndex in entriesBySpineIndex.keys.sorted() {
|
||||
guard let entry = entriesBySpineIndex[spineIndex] else { continue }
|
||||
builder.add(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
|
||||
256 + pageMap.entries.count * 96 + resolvedCount * 16
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBBackgroundPriorityPolicy {
|
||||
|
||||
let hotRadius: Int
|
||||
|
||||
let warmRadius: Int
|
||||
|
||||
let maxWarmJumpAnchors: Int
|
||||
|
||||
let coldLaneShare: Double
|
||||
|
||||
static let `default` = RDEPUBBackgroundPriorityPolicy(
|
||||
hotRadius: 24,
|
||||
warmRadius: 96,
|
||||
maxWarmJumpAnchors: 2,
|
||||
coldLaneShare: 0.15
|
||||
)
|
||||
|
||||
static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
|
||||
let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
|
||||
let warmRadius = min(max(hotRadius * 3, 32), 192)
|
||||
return RDEPUBBackgroundPriorityPolicy(
|
||||
hotRadius: hotRadius,
|
||||
warmRadius: warmRadius,
|
||||
maxWarmJumpAnchors: 2,
|
||||
coldLaneShare: 0.15
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBPriorityBand: Int, Comparable {
|
||||
case hot = 0
|
||||
case warmPrimary = 1
|
||||
case warmSecondary = 2
|
||||
case cold = 3
|
||||
|
||||
static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool {
|
||||
lhs.rawValue < rhs.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBWarmJumpAnchor {
|
||||
let spineIndex: Int
|
||||
let timestamp: CFAbsoluteTime
|
||||
let sequenceNumber: Int
|
||||
}
|
||||
|
||||
struct RDEPUBMetadataParseWorkItem {
|
||||
let spineIndex: Int
|
||||
let generation: Int
|
||||
let priorityBand: RDEPUBPriorityBand
|
||||
|
||||
var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
|
||||
(priorityBand.rawValue, 0, 0, spineIndex)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private(set) var policy: RDEPUBBackgroundPriorityPolicy
|
||||
|
||||
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
|
||||
private let warmAnchorsLock = NSLock()
|
||||
|
||||
private(set) var currentGeneration: Int = 0
|
||||
|
||||
private var coldCursor: Int = 0
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
self.policy = .default
|
||||
}
|
||||
|
||||
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
|
||||
policy = newPolicy
|
||||
}
|
||||
|
||||
func addWarmAnchor(spineIndex: Int) {
|
||||
let anchor = RDEPUBWarmJumpAnchor(
|
||||
spineIndex: spineIndex,
|
||||
timestamp: CFAbsoluteTimeGetCurrent(),
|
||||
sequenceNumber: currentGeneration
|
||||
)
|
||||
|
||||
warmAnchorsLock.lock()
|
||||
warmAnchors.insert(anchor, at: 0)
|
||||
|
||||
if warmAnchors.count > policy.maxWarmJumpAnchors {
|
||||
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
|
||||
}
|
||||
warmAnchorsLock.unlock()
|
||||
|
||||
currentGeneration += 1
|
||||
coldCursor = 0
|
||||
|
||||
}
|
||||
|
||||
func makeMetadataPriorityOrder(
|
||||
allBuildableIndices: [Int],
|
||||
currentSpineIndex: Int?,
|
||||
cachedSpineIndices: Set<Int>
|
||||
) -> [Int] {
|
||||
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
guard !uncachedIndices.isEmpty else { return [] }
|
||||
|
||||
// M-03: Snapshot warmAnchors under lock since it's written on main thread
|
||||
// and read on background thread.
|
||||
warmAnchorsLock.lock()
|
||||
let warmAnchorsSnapshot = warmAnchors
|
||||
warmAnchorsLock.unlock()
|
||||
|
||||
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
|
||||
let band = classifySpineIndex(
|
||||
spineIndex: spineIndex,
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
warmAnchors: warmAnchorsSnapshot
|
||||
)
|
||||
return (spineIndex, band)
|
||||
}
|
||||
|
||||
let sorted = items.sorted { lhs, rhs in
|
||||
|
||||
if lhs.band != rhs.band {
|
||||
return lhs.band < rhs.band
|
||||
}
|
||||
|
||||
let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
|
||||
let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
|
||||
if lhsDistanceToCurrent != rhsDistanceToCurrent {
|
||||
return lhsDistanceToCurrent < rhsDistanceToCurrent
|
||||
}
|
||||
|
||||
return lhs.spineIndex < rhs.spineIndex
|
||||
}
|
||||
|
||||
return sorted.map { $0.spineIndex }
|
||||
}
|
||||
|
||||
private func classifySpineIndex(
|
||||
spineIndex: Int,
|
||||
currentSpineIndex: Int?,
|
||||
warmAnchors: [RDEPUBWarmJumpAnchor]
|
||||
) -> RDEPUBPriorityBand {
|
||||
|
||||
if let current = currentSpineIndex {
|
||||
let distance = abs(spineIndex - current)
|
||||
if distance <= policy.hotRadius {
|
||||
return .hot
|
||||
}
|
||||
}
|
||||
|
||||
for (index, anchor) in warmAnchors.enumerated() {
|
||||
let distance = abs(spineIndex - anchor.spineIndex)
|
||||
if distance <= policy.warmRadius {
|
||||
return index == 0 ? .warmPrimary : .warmSecondary
|
||||
}
|
||||
}
|
||||
|
||||
return .cold
|
||||
}
|
||||
|
||||
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
|
||||
warmAnchorsLock.lock()
|
||||
defer { warmAnchorsLock.unlock() }
|
||||
return warmAnchors
|
||||
}
|
||||
|
||||
func reset() {
|
||||
warmAnchorsLock.lock()
|
||||
warmAnchors.removeAll()
|
||||
warmAnchorsLock.unlock()
|
||||
currentGeneration = 0
|
||||
coldCursor = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBJumpSession {
|
||||
|
||||
let anchorSpineIndex: Int
|
||||
|
||||
let createdAt: CFAbsoluteTime
|
||||
|
||||
let protectedSpineIndices: Set<Int>
|
||||
|
||||
let sequenceNumber: Int
|
||||
|
||||
let expiresAt: CFAbsoluteTime
|
||||
|
||||
let reason: Reason
|
||||
|
||||
enum Reason {
|
||||
case tableOfContentsJump
|
||||
case bookmarkJump
|
||||
case searchJump
|
||||
}
|
||||
|
||||
enum EndReason {
|
||||
|
||||
case coverageComplete
|
||||
|
||||
case navigatedAway
|
||||
|
||||
case timeout
|
||||
|
||||
case superseded
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBJumpSessionPolicy: Equatable {
|
||||
|
||||
public let exitPageThreshold: Int
|
||||
|
||||
public let timeout: TimeInterval
|
||||
|
||||
public let idleGracePeriod: TimeInterval
|
||||
|
||||
public let protectedNeighborRadius: Int
|
||||
|
||||
public static let `default` = RDEPUBJumpSessionPolicy(
|
||||
exitPageThreshold: 6,
|
||||
timeout: 20,
|
||||
idleGracePeriod: 1.5,
|
||||
protectedNeighborRadius: 1
|
||||
)
|
||||
|
||||
public init(
|
||||
exitPageThreshold: Int = 6,
|
||||
timeout: TimeInterval = 20,
|
||||
idleGracePeriod: TimeInterval = 1.5,
|
||||
protectedNeighborRadius: Int = 1
|
||||
) {
|
||||
self.exitPageThreshold = exitPageThreshold
|
||||
self.timeout = timeout
|
||||
self.idleGracePeriod = idleGracePeriod
|
||||
self.protectedNeighborRadius = protectedNeighborRadius
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBJumpSessionManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private(set) var activeSession: RDEPUBJumpSession?
|
||||
|
||||
private var nextSequenceNumber: Int = 0
|
||||
|
||||
private var consecutivePageCount: Int = 0
|
||||
|
||||
private var lastPageDirection: PageDirection?
|
||||
|
||||
private var lastActivityTime: CFAbsoluteTime = 0
|
||||
|
||||
enum PageDirection {
|
||||
case forward
|
||||
case backward
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createSession(
|
||||
anchorSpineIndex: Int,
|
||||
reason: RDEPUBJumpSession.Reason,
|
||||
totalSpineCount: Int
|
||||
) -> RDEPUBJumpSession {
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
var protectedIndices: Set<Int> = [anchorSpineIndex]
|
||||
for offset in 1...policy.protectedNeighborRadius {
|
||||
let lower = anchorSpineIndex - offset
|
||||
let upper = anchorSpineIndex + offset
|
||||
if lower >= 0 {
|
||||
protectedIndices.insert(lower)
|
||||
}
|
||||
if upper < totalSpineCount {
|
||||
protectedIndices.insert(upper)
|
||||
}
|
||||
}
|
||||
|
||||
nextSequenceNumber += 1
|
||||
let session = RDEPUBJumpSession(
|
||||
anchorSpineIndex: anchorSpineIndex,
|
||||
createdAt: now,
|
||||
protectedSpineIndices: protectedIndices,
|
||||
sequenceNumber: nextSequenceNumber,
|
||||
expiresAt: now + policy.timeout,
|
||||
reason: reason
|
||||
)
|
||||
|
||||
activeSession = session
|
||||
consecutivePageCount = 0
|
||||
lastPageDirection = nil
|
||||
lastActivityTime = now
|
||||
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
|
||||
guard activeSession != nil else { return }
|
||||
|
||||
let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward
|
||||
lastActivityTime = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
if direction == lastPageDirection {
|
||||
consecutivePageCount += 1
|
||||
} else {
|
||||
consecutivePageCount = 1
|
||||
lastPageDirection = direction
|
||||
}
|
||||
}
|
||||
|
||||
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
|
||||
guard let session = activeSession else {
|
||||
return true
|
||||
}
|
||||
|
||||
let protectedIndices = session.protectedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
|
||||
Double(protectedIndices.count)
|
||||
|
||||
return coverageRatio >= 0.8
|
||||
}
|
||||
|
||||
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
|
||||
guard let session = activeSession else { return nil }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
|
||||
if now >= session.expiresAt {
|
||||
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
|
||||
return .timeout
|
||||
}
|
||||
}
|
||||
|
||||
if !session.protectedSpineIndices.contains(currentSpineIndex) {
|
||||
if consecutivePageCount >= policy.exitPageThreshold {
|
||||
return .navigatedAway
|
||||
}
|
||||
} else {
|
||||
|
||||
consecutivePageCount = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
|
||||
guard let session = activeSession else { return }
|
||||
activeSession = nil
|
||||
consecutivePageCount = 0
|
||||
lastPageDirection = nil
|
||||
}
|
||||
|
||||
func clearSession() {
|
||||
activeSession = nil
|
||||
consecutivePageCount = 0
|
||||
lastPageDirection = nil
|
||||
nextSequenceNumber = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Memory footprint probe for the long-chapter optimization work
|
||||
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2). Enabled with the
|
||||
/// `--demo-memory-probe` launch argument; logs phys_footprint at chapter
|
||||
/// load, every 20 page turns, settings invalidation, and rotation.
|
||||
enum RDEPUBMemoryProbe {
|
||||
|
||||
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-memory-probe")
|
||||
|
||||
private static let pageTurnLogStride = 20
|
||||
|
||||
/// Main-thread only (page turns are delivered on main).
|
||||
private static var pageTurnCount = 0
|
||||
|
||||
static func logPageTurn() {
|
||||
guard isEnabled else { return }
|
||||
pageTurnCount += 1
|
||||
guard pageTurnCount % pageTurnLogStride == 0 else { return }
|
||||
log("pageTurn count=\(pageTurnCount)")
|
||||
}
|
||||
|
||||
static func log(_ event: String) {
|
||||
guard isEnabled else { return }
|
||||
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB))
|
||||
}
|
||||
|
||||
/// Current phys_footprint in MB. Cheap enough (one task_info call) to
|
||||
/// surface in the demo state snapshot for automated memory assertions.
|
||||
static var footprintMB: Double {
|
||||
Double(currentFootprint()) / 1_048_576
|
||||
}
|
||||
|
||||
/// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
|
||||
private static func currentFootprint() -> UInt64 {
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(
|
||||
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
|
||||
)
|
||||
let result = withUnsafeMutablePointer(to: &info) { pointer in
|
||||
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
guard result == KERN_SUCCESS else { return 0 }
|
||||
return info.phys_footprint
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBMetadataParseCancellationController {
|
||||
|
||||
let token: UUID
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private weak var queue: OperationQueue?
|
||||
|
||||
private var cancelled = false
|
||||
|
||||
init(token: UUID) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func attach(queue: OperationQueue) {
|
||||
let shouldCancelImmediately: Bool
|
||||
lock.lock()
|
||||
self.queue = queue
|
||||
shouldCancelImmediately = cancelled
|
||||
lock.unlock()
|
||||
|
||||
if shouldCancelImmediately {
|
||||
queue.cancelAllOperations()
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let queueToCancel: OperationQueue?
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
queueToCancel = queue
|
||||
lock.unlock()
|
||||
queueToCancel?.cancelAllOperations()
|
||||
}
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.lock()
|
||||
let value = cancelled
|
||||
lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBMetadataParseWorker {
|
||||
|
||||
private static let maxRetryCount = 3
|
||||
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
||||
|
||||
private final class ParseState {
|
||||
|
||||
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
|
||||
|
||||
var totalResolvedCount: Int
|
||||
|
||||
var lastAppliedCount: Int
|
||||
|
||||
init(
|
||||
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
|
||||
totalResolvedCount: Int,
|
||||
lastAppliedCount: Int
|
||||
) {
|
||||
self.summariesBySpineIndex = summariesBySpineIndex
|
||||
self.totalResolvedCount = totalResolvedCount
|
||||
self.lastAppliedCount = lastAppliedCount
|
||||
}
|
||||
}
|
||||
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
|
||||
weak var context: RDEPUBReaderContext?
|
||||
|
||||
let cancellationController: RDEPUBMetadataParseCancellationController
|
||||
|
||||
let pageMapRefreshInterval: Int
|
||||
|
||||
private let token: UUID
|
||||
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
private let pageSize: CGSize
|
||||
|
||||
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
private let style: RDEPUBTextRenderStyle
|
||||
|
||||
private let renderSignature: String
|
||||
|
||||
private let allBuildableIndices: [Int]
|
||||
|
||||
private let summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
private let workerCount: Int
|
||||
|
||||
private let contentHashBySpineIndex: [Int: String]
|
||||
|
||||
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
||||
|
||||
private let progressLogStride = 4
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
cancellationController: RDEPUBMetadataParseCancellationController,
|
||||
token: UUID,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication
|
||||
) {
|
||||
self.context = context
|
||||
self.cancellationController = cancellationController
|
||||
self.token = token
|
||||
self.parser = parser
|
||||
self.publication = publication
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
self.pageSize = pageSize
|
||||
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
self.style = context.currentTextRenderStyle()
|
||||
self.renderSignature = context.currentRenderSignature()
|
||||
self.allBuildableIndices = publication.spine.indices.filter { index in
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
self.summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
||||
|
||||
var hashes: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
guard let href = publication.spine.indices.contains(spineIndex)
|
||||
? publication.spine[spineIndex].href : nil,
|
||||
let html = parser.htmlString(forRelativePath: href) else {
|
||||
hashes[spineIndex] = ""
|
||||
continue
|
||||
}
|
||||
hashes[spineIndex] = html.rd_sha256Hex
|
||||
}
|
||||
self.contentHashBySpineIndex = hashes
|
||||
|
||||
let ctx = context
|
||||
let spine = publication.spine
|
||||
let sig = renderSignature
|
||||
self.catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = spine[spineIndex]
|
||||
return (
|
||||
key: ctx.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: hashes[spineIndex] ?? "",
|
||||
renderSignature: sig
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let cancellationController = self.cancellationController
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [self] in
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"workerDispatched token=\(token.uuidString)"
|
||||
)
|
||||
let context = self.context
|
||||
guard let context,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
|
||||
)
|
||||
return
|
||||
}
|
||||
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
|
||||
guard context.controller != nil,
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible() {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||
|
||||
let restored = self.summaryDiskCache?.readAll(keys: self.catalog)
|
||||
let cachedSummaries = restored?.summaries ?? [:]
|
||||
let cachedSpineIndices = Set(cachedSummaries.keys)
|
||||
let resultLock = NSLock()
|
||||
let parseState = ParseState(
|
||||
summariesBySpineIndex: cachedSummaries,
|
||||
totalResolvedCount: cachedSpineIndices.count,
|
||||
lastAppliedCount: cachedSpineIndices.count
|
||||
)
|
||||
|
||||
if !cachedSpineIndices.isEmpty {
|
||||
let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
||||
}
|
||||
}
|
||||
|
||||
let prioritizedSpineIndices: [Int]
|
||||
if let priorityManager = context.runtime?.backgroundPriorityManager {
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
|
||||
allBuildableIndices: self.allBuildableIndices,
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
cachedSpineIndices: cachedSpineIndices
|
||||
)
|
||||
} else {
|
||||
prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
}
|
||||
|
||||
let uncachedSpineIndices = prioritizedSpineIndices
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
|
||||
)
|
||||
self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
|
||||
guard !cancellationController.isCancelled,
|
||||
context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"workerAbortedDuringCooldown"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
|
||||
)
|
||||
|
||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||
var totalRenderMs: Double = 0
|
||||
var totalWriteMs: Double = 0
|
||||
var totalMergeMs: Double = 0
|
||||
var completedChapters = 0
|
||||
var failedChapters = 0
|
||||
let timingLock = NSLock()
|
||||
|
||||
let queue = OperationQueue()
|
||||
queue.name = "com.RDEpubReader.metadata.parse"
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = self.workerCount
|
||||
cancellationController.attach(queue: queue)
|
||||
|
||||
let refreshInterval = self.pageMapRefreshInterval
|
||||
|
||||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||||
let operation = BlockOperation()
|
||||
operation.addExecutionBlock { [weak operation] in
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
|
||||
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return nil
|
||||
}
|
||||
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
guard let result = try chapterBuilder.buildChapter(
|
||||
parser: self.parser,
|
||||
publication: self.publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: self.pageSize,
|
||||
style: self.style
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return nil
|
||||
}
|
||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||
|
||||
let chapter = result.chapter
|
||||
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: self.renderSignature
|
||||
)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return nil
|
||||
}
|
||||
let writeStart = CFAbsoluteTimeGetCurrent()
|
||||
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
||||
|
||||
timingLock.lock()
|
||||
totalRenderMs += renderElapsed
|
||||
totalWriteMs += writeElapsed
|
||||
completedChapters += 1
|
||||
timingLock.unlock()
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
guard let renderResult else { return }
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return
|
||||
}
|
||||
|
||||
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||
resultLock.lock()
|
||||
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
||||
parseState.totalResolvedCount += 1
|
||||
let resolvedCount = parseState.totalResolvedCount
|
||||
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|
||||
|| resolvedCount == cachedSpineIndices.count + 1
|
||||
|| resolvedCount % self.progressLogStride == 0
|
||||
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
||||
|| parseState.totalResolvedCount == self.allBuildableIndices.count {
|
||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||
snapshot = parseState.summariesBySpineIndex
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if shouldLogProgress {
|
||||
let progressPercent = Self.progressPercent(
|
||||
resolved: resolvedCount,
|
||||
total: self.allBuildableIndices.count
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
|
||||
)
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let partialMap = self.buildPageMap(summaries: snapshot)
|
||||
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||
timingLock.lock()
|
||||
totalMergeMs += mergeElapsed
|
||||
timingLock.unlock()
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
guard !cancellationController.isCancelled,
|
||||
context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
operation?.isCancelled != true else {
|
||||
return
|
||||
}
|
||||
timingLock.lock()
|
||||
failedChapters += 1
|
||||
timingLock.unlock()
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
|
||||
)
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: 0,
|
||||
resultLock: resultLock,
|
||||
parseState: parseState,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
}
|
||||
}
|
||||
queue.addOperation(operation)
|
||||
}
|
||||
queue.waitUntilAllOperationsAreFinished()
|
||||
if !cancellationController.isCancelled,
|
||||
context.paginationToken == token,
|
||||
context.controller != nil {
|
||||
self.summaryDiskCache?.flushPendingWrites()
|
||||
}
|
||||
|
||||
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
||||
timingLock.lock()
|
||||
let renderTotal = Int(totalRenderMs)
|
||||
let writeTotal = Int(totalWriteMs)
|
||||
let mergeTotal = Int(totalMergeMs)
|
||||
let rendered = completedChapters
|
||||
let failed = failedChapters
|
||||
timingLock.unlock()
|
||||
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = self.workerCount
|
||||
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
|
||||
)
|
||||
|
||||
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
||||
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
||||
let lowerSpine = resolvedSpineIndices.min() ?? 0
|
||||
let upperSpine = resolvedSpineIndices.max() ?? 0
|
||||
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
|
||||
|
||||
let segment = RDEPUBBackgroundCoverageSegment(
|
||||
lowerSpineIndex: lowerSpine,
|
||||
upperSpineIndex: upperSpine,
|
||||
pageMap: pageMap,
|
||||
resolvedSpineIndices: resolvedSpineIndices,
|
||||
generatedAt: CFAbsoluteTimeGetCurrent(),
|
||||
renderSignature: self.renderSignature,
|
||||
estimatedMemoryBytes: estimatedBytes
|
||||
)
|
||||
coverageStore.addSegment(segment)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func waitForReadingInteractionToSettle(
|
||||
cancellationController: RDEPUBMetadataParseCancellationController? = nil
|
||||
) {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let maxWaitIterations = 100 // ~8 seconds max wait
|
||||
var iteration = 0
|
||||
while context?.controller != nil,
|
||||
cancellationController?.isCancelled != true,
|
||||
iteration < maxWaitIterations {
|
||||
let elapsed = context?.secondsSinceLastUserNavigation() ?? 0
|
||||
if elapsed >= backgroundInteractionCooldown { break }
|
||||
semaphore.wait(timeout: .now() + 0.08)
|
||||
iteration += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry(
|
||||
spineIndex: Int,
|
||||
retryCount: Int,
|
||||
resultLock: NSLock,
|
||||
parseState: ParseState,
|
||||
cancellationController: RDEPUBMetadataParseCancellationController
|
||||
) {
|
||||
guard retryCount < Self.maxRetryCount else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == self.token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig)
|
||||
guard let result = try chapterBuilder.buildChapter(
|
||||
parser: self.parser,
|
||||
publication: self.publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: self.pageSize,
|
||||
style: self.style
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == self.token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
let chapter = result.chapter
|
||||
let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: self.renderSignature
|
||||
)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == self.token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
self.summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
|
||||
resultLock.lock()
|
||||
parseState.summariesBySpineIndex[spineIndex] = summary
|
||||
parseState.totalResolvedCount += 1
|
||||
let shouldRefresh =
|
||||
parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval
|
||||
|| parseState.totalResolvedCount == self.allBuildableIndices.count
|
||||
if shouldRefresh {
|
||||
parseState.lastAppliedCount = parseState.totalResolvedCount
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if shouldRefresh {
|
||||
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Metadata",
|
||||
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == self.token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: retryCount + 1,
|
||||
resultLock: resultLock,
|
||||
parseState: parseState,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func progressPercent(resolved: Int, total: Int) -> Int {
|
||||
guard total > 0 else { return 0 }
|
||||
return Int((Double(resolved) / Double(total) * 100.0).rounded())
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache else { return nil }
|
||||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||||
return nil
|
||||
}
|
||||
let restored = summaryDiskCache.readAll(keys: catalog)
|
||||
guard restored.summaries.count == catalog.count else {
|
||||
return nil
|
||||
}
|
||||
return restored.mapBuilder.build()
|
||||
}
|
||||
|
||||
private func buildPageMap(
|
||||
summaries: [Int: RDEPUBChapterSummary]
|
||||
) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for item in catalog {
|
||||
guard let summary = summaries[item.spineIndex] else { continue }
|
||||
builder.add(
|
||||
spineIndex: item.spineIndex,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageCount: summary.pageCount,
|
||||
fragmentOffsets: summary.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBPageMapTakeoverDecision {
|
||||
|
||||
case keepCurrentWindow
|
||||
|
||||
case expandWindow(RDEPUBBackgroundCoverageSegment)
|
||||
|
||||
case segmentReplace(RDEPUBBackgroundCoverageSegment)
|
||||
|
||||
case fullReplace(RDEPUBBookPageMap)
|
||||
}
|
||||
|
||||
final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func evaluateTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap?,
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment?,
|
||||
currentWindow: RDEPUBBookPageMap?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
|
||||
guard let currentWindow else {
|
||||
if let candidatePageMap {
|
||||
return .fullReplace(candidatePageMap)
|
||||
}
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
|
||||
let lastBuildableSpineIndex = context.publication?.spine.indices
|
||||
.reversed()
|
||||
.first(where: { index in
|
||||
guard let item = context.publication?.spine[index] else { return false }
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}) ?? 0
|
||||
|
||||
if let jumpSession {
|
||||
let protectedIndices = jumpSession.protectedSpineIndices
|
||||
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
|
||||
|
||||
if let candidateSegment {
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
|
||||
Double(protectedIndices.count)
|
||||
if coverageRatio < 0.8 {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateTakeover: keepCurrentWindow — jumpSession coverageRatio=\(String(format: "%.2f", coverageRatio)) protected=\(protectedIndices.count) candidateChapters=\(candidateIndices.count)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let currentSpineIndex {
|
||||
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
|
||||
|
||||
if requiresAdjacentCoverage {
|
||||
if let candidateSegment {
|
||||
let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1)
|
||||
let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1)
|
||||
if !hasPrev || !hasNext {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let candidateSegment {
|
||||
let currentRenderSignature = context.currentRenderSignature()
|
||||
if candidateSegment.renderSignature != currentRenderSignature {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateTakeover: keepCurrentWindow — renderSignature mismatch segment=\(candidateSegment.renderSignature) current=\(currentRenderSignature)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
if let candidateSegment {
|
||||
return evaluateSegmentTakeover(
|
||||
candidateSegment: candidateSegment,
|
||||
currentWindow: currentWindow,
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
lastBuildableSpineIndex: lastBuildableSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
if let candidatePageMap {
|
||||
return evaluateFullPageMapTakeover(
|
||||
candidatePageMap: candidatePageMap,
|
||||
currentWindow: currentWindow,
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
lastBuildableSpineIndex: lastBuildableSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
private func evaluateSegmentTakeover(
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
currentSpineIndex: Int?,
|
||||
lastBuildableSpineIndex: Int
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
currentSpineIndex == lastBuildableSpineIndex
|
||||
if !hasPrev || !hasNext {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
|
||||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
|
||||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
|
||||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
|
||||
|
||||
if isContinuous {
|
||||
|
||||
return .expandWindow(candidateSegment)
|
||||
} else {
|
||||
|
||||
let overlap = currentIndices.intersection(candidateIndices)
|
||||
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
|
||||
if overlapRatio > 0.5 {
|
||||
|
||||
return .segmentReplace(candidateSegment)
|
||||
}
|
||||
}
|
||||
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
private func evaluateFullPageMapTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
currentSpineIndex: Int?,
|
||||
lastBuildableSpineIndex: Int
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||
let currentEntries = currentWindow.entries.count
|
||||
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: keepCurrentWindow — currentSpine=\(currentSpineIndex) not in candidate chapters=\(candidateIndices.count)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
currentSpineIndex == lastBuildableSpineIndex
|
||||
if !hasPrev || !hasNext {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: keepCurrentWindow — adjacentCoverage missing hasPrev=\(hasPrev) hasNext=\(hasNext) currentSpine=\(currentSpineIndex)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
|
||||
if coversCurrentWindow {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
||||
)
|
||||
return .fullReplace(candidatePageMap)
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
func protectedSpineIndices(
|
||||
currentSpineIndex: Int?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
) -> Set<Int> {
|
||||
var indices: Set<Int> = []
|
||||
|
||||
if let currentSpineIndex {
|
||||
indices.insert(currentSpineIndex)
|
||||
|
||||
if currentSpineIndex > 0 {
|
||||
indices.insert(currentSpineIndex - 1)
|
||||
}
|
||||
indices.insert(currentSpineIndex + 1)
|
||||
}
|
||||
|
||||
if let jumpSession {
|
||||
indices.formUnion(jumpSession.protectedSpineIndices)
|
||||
}
|
||||
|
||||
return indices
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import UIKit
|
||||
|
||||
enum RDEPUBPendingPageMapUpdateKind {
|
||||
case reconcileFullMap
|
||||
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
|
||||
case appendForward
|
||||
}
|
||||
|
||||
struct RDEPUBPendingPageMapUpdate {
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
let kind: RDEPUBPendingPageMapUpdateKind
|
||||
}
|
||||
|
||||
final class RDEPUBPresentationRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
|
||||
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
||||
|
||||
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
|
||||
private var pendingCommitRetryWorkItem: DispatchWorkItem?
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
jumpSessionManager: RDEPUBJumpSessionManager,
|
||||
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
|
||||
) {
|
||||
self.context = context
|
||||
self.locationCoordinator = locationCoordinator
|
||||
self.jumpSessionManager = jumpSessionManager
|
||||
self.reconciliationCoordinator = reconciliationCoordinator
|
||||
}
|
||||
|
||||
func applyBookPageMap(
|
||||
_ bookPageMap: RDEPUBBookPageMap,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
finishPagination: (RDEPUBLocation?) -> Void
|
||||
) {
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
finishPagination(restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
|
||||
)
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
kind: .reconcileFullMap
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func commitPendingPageMapUpdateIfNeeded() {
|
||||
guard let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
guard !context.pendingPageMapUpdates.isEmpty else {
|
||||
cancelPendingCommitRetry()
|
||||
return
|
||||
}
|
||||
|
||||
guard !controller.isRepaginating else {
|
||||
schedulePendingCommitRetry()
|
||||
return
|
||||
}
|
||||
guard !readerView.isPageCurlTransitioning else {
|
||||
schedulePendingCommitRetry()
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingCommitRetry()
|
||||
|
||||
let rankedUpdates = rankedPendingPageMapUpdates()
|
||||
for (index, update) in rankedUpdates {
|
||||
if commitPendingPageMapUpdate(
|
||||
update,
|
||||
at: index,
|
||||
readerView: readerView,
|
||||
controller: controller
|
||||
) {
|
||||
if !context.pendingPageMapUpdates.isEmpty {
|
||||
schedulePendingCommitRetry()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !context.pendingPageMapUpdates.isEmpty {
|
||||
schedulePendingCommitRetry()
|
||||
}
|
||||
}
|
||||
|
||||
func queueExtendedPartialPageMap(
|
||||
_ bookPageMap: RDEPUBBookPageMap,
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?
|
||||
) {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
kind: .extendPartial(
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
}
|
||||
|
||||
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
enqueuePendingPageMapUpdate(
|
||||
RDEPUBPendingPageMapUpdate(
|
||||
pageMap: bookPageMap,
|
||||
kind: .appendForward
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
EPUBPage(
|
||||
spineIndex: entry.spineIndex,
|
||||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: entry.pageCount,
|
||||
chapterTitle: entry.title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
let chapters = bookPageMap.entries.map { entry in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: entry.spineIndex,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
private func applyFullPageMapReplacement(
|
||||
_ newPageMap: RDEPUBBookPageMap,
|
||||
readerView: RDEpubReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) {
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
let livePageBeforeApply = readerView.currentPage + 1
|
||||
// Resolved against the outgoing page map. When it round-trips to the live
|
||||
// page, the location faithfully describes what is on screen, so whatever
|
||||
// page it resolves to in the new map is authoritative even if the two maps
|
||||
// number pages differently (partial-window -> full-book takeover).
|
||||
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
|
||||
|
||||
context.textBook = nil
|
||||
applyPageMapToLiveModel(newPageMap)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
|
||||
)
|
||||
|
||||
if let currentLocation {
|
||||
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
|
||||
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
|
||||
resolvedTargetPage,
|
||||
livePageBeforeApply: livePageBeforeApply,
|
||||
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
|
||||
)
|
||||
|
||||
if shouldTrustResolvedLocation,
|
||||
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||
return
|
||||
}
|
||||
|
||||
let fallbackPage = max(livePageBeforeApply, 1)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
|
||||
)
|
||||
rebindVisiblePage(
|
||||
to: fallbackPage - 1,
|
||||
readerView: readerView
|
||||
)
|
||||
} else {
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
if let currentLocation,
|
||||
context.normalizedSpineIndex(for: currentLocation) != nil,
|
||||
let activeSession = jumpSessionManager.activeSession {
|
||||
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
|
||||
let protectedIndices = activeSession.protectedSpineIndices
|
||||
if protectedIndices.isSubset(of: candidateIndices) {
|
||||
jumpSessionManager.endSession(.coverageComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
|
||||
var updates = context.pendingPageMapUpdates
|
||||
if let existingIndex = updates.firstIndex(where: {
|
||||
pendingPageMapUpdateKindMatches($0.kind, update.kind)
|
||||
}) {
|
||||
let existing = updates[existingIndex]
|
||||
if shouldReplacePendingPageMapUpdate(existing, with: update) {
|
||||
updates[existingIndex] = update
|
||||
}
|
||||
} else {
|
||||
updates.append(update)
|
||||
}
|
||||
context.pendingPageMapUpdates = updates
|
||||
commitPendingPageMapUpdateIfNeeded()
|
||||
}
|
||||
|
||||
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
|
||||
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
|
||||
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
|
||||
}
|
||||
}
|
||||
|
||||
private func commitPendingPageMapUpdate(
|
||||
_ update: RDEPUBPendingPageMapUpdate,
|
||||
at index: Int,
|
||||
readerView: RDEpubReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) -> Bool {
|
||||
switch update.kind {
|
||||
case .reconcileFullMap:
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: update.pageMap,
|
||||
candidateSegment: nil,
|
||||
currentWindow: context.bookPageMap,
|
||||
jumpSession: jumpSessionManager.activeSession
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .keepCurrentWindow:
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)"
|
||||
)
|
||||
removePendingPageMapUpdate(at: index)
|
||||
return false
|
||||
|
||||
case .fullReplace(let newPageMap):
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
return true
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
removePendingPageMapUpdate(at: index)
|
||||
return false
|
||||
}
|
||||
|
||||
case .extendPartial(let capturedPageNumber, let currentLocation):
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap)
|
||||
let livePageNumber = max(readerView.currentPage, 0) + 1
|
||||
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
|
||||
)
|
||||
if shouldTrustCapturedLocation,
|
||||
let currentLocation,
|
||||
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
|
||||
return true
|
||||
}
|
||||
// Prefer the live readerView page when the user has moved since the
|
||||
// extension request was created; otherwise a stale captured location can
|
||||
// snap pageCurl back to the previous page after the turn completes.
|
||||
let livePageIndex = max(readerView.currentPage, 0)
|
||||
rebindVisiblePage(
|
||||
to: livePageIndex,
|
||||
readerView: readerView
|
||||
)
|
||||
return true
|
||||
|
||||
case .appendForward:
|
||||
removePendingPageMapUpdate(at: index)
|
||||
applyPageMapToLiveModel(update.pageMap)
|
||||
readerView.reloadPageCountOnly()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func rebindVisiblePage(to pageIndex: Int, readerView: RDEpubReaderView) {
|
||||
if pageIndex == readerView.currentPage {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
|
||||
)
|
||||
readerView.reloadPageCountOnly()
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
|
||||
)
|
||||
if readerView.currentDisplayType == .pageCurl {
|
||||
if readerView.isPageCurlTransitioning {
|
||||
// Defer the transition until the current page-curl animation completes,
|
||||
// and re-read the live page at that time to avoid jumping to a stale position.
|
||||
DispatchQueue.main.async { [weak readerView] in
|
||||
guard let readerView, !readerView.isPageCurlTransitioning else { return }
|
||||
let livePageIndex = max(readerView.currentPage, 0)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
|
||||
)
|
||||
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
|
||||
}
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: pageIndex, animated: false)
|
||||
}
|
||||
} else {
|
||||
readerView.reloadPageCountOnly()
|
||||
if pageIndex != readerView.currentPage {
|
||||
readerView.transitionToPage(pageNum: pageIndex, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rebindVisibleLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
readerView: RDEpubReaderView,
|
||||
controller: RDEPUBReaderController
|
||||
) -> Bool {
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil,
|
||||
context.runtime?.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: targetPageNumber,
|
||||
allowSynchronousLoad: false
|
||||
) == false {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
|
||||
)
|
||||
|
||||
rebindVisiblePage(
|
||||
to: max(targetPageNumber - 1, 0),
|
||||
readerView: readerView
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
|
||||
context.bookPageMap = pageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
|
||||
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
|
||||
}
|
||||
|
||||
private func removePendingPageMapUpdate(at index: Int) {
|
||||
var updates = context.pendingPageMapUpdates
|
||||
guard updates.indices.contains(index) else { return }
|
||||
updates.remove(at: index)
|
||||
context.pendingPageMapUpdates = updates
|
||||
}
|
||||
|
||||
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
|
||||
let updates = context.pendingPageMapUpdates.filter { update in
|
||||
update.pageMap.totalChapters > liveMap.totalChapters
|
||||
|| (
|
||||
update.pageMap.totalChapters == liveMap.totalChapters
|
||||
&& update.pageMap.totalPages > liveMap.totalPages
|
||||
)
|
||||
}
|
||||
context.pendingPageMapUpdates = updates
|
||||
}
|
||||
|
||||
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
|
||||
switch kind {
|
||||
case .extendPartial:
|
||||
return 3
|
||||
case .appendForward:
|
||||
return 2
|
||||
case .reconcileFullMap:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private func pendingPageMapUpdateKindMatches(
|
||||
_ lhs: RDEPUBPendingPageMapUpdateKind,
|
||||
_ rhs: RDEPUBPendingPageMapUpdateKind
|
||||
) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.reconcileFullMap, .reconcileFullMap),
|
||||
(.appendForward, .appendForward),
|
||||
(.extendPartial, .extendPartial):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldReplacePendingPageMapUpdate(
|
||||
_ existing: RDEPUBPendingPageMapUpdate,
|
||||
with candidate: RDEPUBPendingPageMapUpdate
|
||||
) -> Bool {
|
||||
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|
||||
|| (
|
||||
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
|
||||
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldTrustFullReplaceResolvedPage(
|
||||
_ resolvedTargetPage: Int?,
|
||||
livePageBeforeApply: Int,
|
||||
locationMatchesLivePage: Bool
|
||||
) -> Bool {
|
||||
guard let resolvedTargetPage else { return false }
|
||||
if locationMatchesLivePage {
|
||||
return true
|
||||
}
|
||||
// The location did not round-trip to the live page in the outgoing map
|
||||
// (stale persisted location or mid-transition), so only follow it when it
|
||||
// stays next to the page the user is actually looking at.
|
||||
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
|
||||
}
|
||||
|
||||
private func schedulePendingCommitRetry() {
|
||||
guard pendingCommitRetryWorkItem == nil else { return }
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingCommitRetryWorkItem = nil
|
||||
self.commitPendingPageMapUpdateIfNeeded()
|
||||
}
|
||||
pendingCommitRetryWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelPendingCommitRetry() {
|
||||
pendingCommitRetryWorkItem?.cancel()
|
||||
pendingCommitRetryWorkItem = nil
|
||||
}
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeBookmarks.first { $0.id == id }
|
||||
}
|
||||
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
if let selection, !selection.isEmpty {
|
||||
applySelectionState(.selected(selection))
|
||||
} else {
|
||||
applySelectionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||
guard let controller else { return }
|
||||
context.selectionState = state
|
||||
switch state {
|
||||
case .idle:
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: nil)
|
||||
case .selecting:
|
||||
break
|
||||
case .selected(let selection):
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: selection)
|
||||
case .committingAction:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
let sourceSelection = selection ?? controller.currentSelection
|
||||
guard let sourceSelection,
|
||||
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
|
||||
!scopedSelection.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let newHighlight = RDEPUBHighlight(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: scopedSelection.location,
|
||||
text: scopedSelection.text,
|
||||
rangeInfo: scopedSelection.rangeInfo,
|
||||
style: style,
|
||||
color: color,
|
||||
note: note
|
||||
)
|
||||
|
||||
let isDuplicate = controller.activeHighlights.contains { highlight in
|
||||
highlight.location.href == newHighlight.location.href &&
|
||||
highlight.location.fragment == newHighlight.location.fragment &&
|
||||
highlight.text == newHighlight.text &&
|
||||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||||
highlight.style == newHighlight.style
|
||||
}
|
||||
guard !isDuplicate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
controller.activeHighlights.append(newHighlight)
|
||||
persistHighlightsAndRefreshContent()
|
||||
updateCurrentSelection(nil)
|
||||
return newHighlight
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let scopedHighlight = scopedHighlight(highlight) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
|
||||
controller.activeHighlights[index] = scopedHighlight
|
||||
} else {
|
||||
controller.activeHighlights.append(scopedHighlight)
|
||||
}
|
||||
persistHighlightsAndRefreshContent()
|
||||
return scopedHighlight
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
let removed = controller.activeHighlights.remove(at: index)
|
||||
persistHighlightsAndRefreshContent()
|
||||
return removed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
controller.activeHighlights[index].note = normalizedNote(note)
|
||||
persistHighlightsAndRefreshContent()
|
||||
return controller.activeHighlights[index]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return navigate(to: highlight, animated: animated)
|
||||
}
|
||||
|
||||
func removeAllHighlights() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
controller.activeHighlights.removeAll()
|
||||
persistHighlightsAndRefreshContent()
|
||||
}
|
||||
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
) -> RDEPUBSelection? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
selection.location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: selection.location.href,
|
||||
progression: selection.location.progression,
|
||||
lastProgression: selection.location.lastProgression,
|
||||
fragment: selection.location.fragment,
|
||||
rangeAnchor: selection.location.rangeAnchor,
|
||||
cfi: selection.location.cfi,
|
||||
lastCFI: selection.location.lastCFI,
|
||||
rangeCFI: selection.location.rangeCFI
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: normalizedLocation,
|
||||
text: selection.text,
|
||||
rangeInfo: selection.rangeInfo,
|
||||
createdAt: selection.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
|
||||
let highlightsController = RDEPUBReaderHighlightsViewController(
|
||||
highlights: controller.activeHighlights,
|
||||
theme: controller.configuration.theme,
|
||||
sectionTitleProvider: { [weak self] highlight in
|
||||
self?.titleForHighlight(highlight)
|
||||
}
|
||||
)
|
||||
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
||||
highlightsController?.dismiss(animated: true) {
|
||||
_ = self?.navigate(to: highlight, animated: true)
|
||||
}
|
||||
}
|
||||
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
||||
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
|
||||
}
|
||||
highlightsController.onDeleteHighlight = { [weak self] highlight in
|
||||
_ = self?.controller?.removeHighlight(id: highlight.id)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: highlightsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights,
|
||||
let currentSelection = controller.currentSelection else {
|
||||
return
|
||||
}
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
|
||||
_ = self?.removeHighlight(id: highlight.id)
|
||||
})
|
||||
if highlight.hasNote {
|
||||
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
|
||||
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = sourceView
|
||||
popover.sourceRect = sourceRect
|
||||
}
|
||||
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
guard let selection else { return }
|
||||
switch action {
|
||||
case .copy:
|
||||
UIPasteboard.general.string = selection.text
|
||||
updateCurrentSelection(nil)
|
||||
case .highlight:
|
||||
createAnnotation(from: selection, style: .highlight)
|
||||
case .annotate:
|
||||
presentAnnotationNoteEditor(for: selection)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
guard bookmark(matching: location) == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let newBookmark = RDEPUBBookmark(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: location,
|
||||
rangeInfo: controller.currentVisibleRangeInfo(),
|
||||
chapterTitle: titleForBookmarkLocation(location),
|
||||
note: normalizedBookmarkNote(note)
|
||||
)
|
||||
controller.activeBookmarks.append(newBookmark)
|
||||
persistBookmarksAndRefreshChrome()
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let existingBookmark = bookmark(matching: location) {
|
||||
_ = removeBookmark(id: existingBookmark.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
return addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
let removed = controller.activeBookmarks.remove(at: index)
|
||||
persistBookmarksAndRefreshChrome()
|
||||
return removed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let bookmark = bookmark(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return controller.restoreReadingLocation(
|
||||
bookmark.location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: bookmark.rangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
|
||||
let bookmarksController = RDEPUBReaderBookmarksViewController(
|
||||
bookmarks: controller.activeBookmarks,
|
||||
theme: controller.configuration.theme
|
||||
)
|
||||
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
|
||||
guard let controller = self?.controller else { return }
|
||||
bookmarksController?.dismiss(animated: true) {
|
||||
_ = controller.restoreReadingLocation(
|
||||
bookmark.location,
|
||||
animated: true,
|
||||
targetHighlightRangeInfo: bookmark.rangeInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
|
||||
_ = self?.controller?.removeBookmark(id: bookmark.id)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: bookmarksController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
highlight.location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: highlight.location.href,
|
||||
progression: highlight.location.progression,
|
||||
lastProgression: highlight.location.lastProgression,
|
||||
fragment: highlight.location.fragment,
|
||||
rangeAnchor: highlight.location.rangeAnchor,
|
||||
cfi: highlight.location.cfi,
|
||||
lastCFI: highlight.location.lastCFI,
|
||||
rangeCFI: highlight.location.rangeCFI
|
||||
)
|
||||
return RDEPUBHighlight(
|
||||
id: highlight.id,
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
location: normalizedLocation,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
style: highlight.style,
|
||||
color: highlight.color,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
let navigationTarget = scopedHighlight(highlight) ?? highlight
|
||||
return controller.restoreReadingLocation(
|
||||
navigationTarget.location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: navigationTarget.rangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
private func persistHighlightsAndRefreshContent() {
|
||||
guard let controller else { return }
|
||||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||||
}
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
refreshVisibleContentPreservingCurrentPage()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentPreservingCurrentPage() {
|
||||
guard let controller else { return }
|
||||
let currentPage = controller.readerView.currentPage
|
||||
guard currentPage >= 0 else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
|
||||
controller.readerView.reloadData()
|
||||
if controller.readerView.currentPage != currentPage {
|
||||
controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
|
||||
self?.createAnnotation(from: selection, style: .highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
||||
self?.createAnnotation(from: selection, style: .underline)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
|
||||
self?.presentAnnotationNoteEditor(for: selection)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = controller.bottomToolView
|
||||
popover.sourceRect = controller.bottomToolView.bounds
|
||||
}
|
||||
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
||||
_ = addAnnotation(from: selection, style: style, note: note)
|
||||
}
|
||||
|
||||
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "输入批注内容"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||||
self?.createAnnotation(
|
||||
from: selection,
|
||||
style: .highlight,
|
||||
note: alert?.textFields?.first?.text
|
||||
)
|
||||
})
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
||||
guard let controller else { return nil }
|
||||
guard let publication = controller.publication,
|
||||
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return controller.flattenedTableOfContents.first { item in
|
||||
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
|
||||
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
|
||||
}?.title
|
||||
}
|
||||
|
||||
private func normalizedNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
|
||||
guard let controller else { return nil }
|
||||
if let currentLocation = controller.currentVisibleLocation(),
|
||||
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
|
||||
return controller.currentTableOfContentsItem?.title
|
||||
}
|
||||
|
||||
return controller.flattenedTableOfContents.last { item in
|
||||
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
|
||||
}?.title
|
||||
}
|
||||
|
||||
private func persistBookmarksAndRefreshChrome() {
|
||||
guard let controller else { return }
|
||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
|
||||
controller.updateReaderChrome()
|
||||
}
|
||||
|
||||
func currentBookmark() -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||
return nil
|
||||
}
|
||||
return bookmark(matching: location)
|
||||
}
|
||||
|
||||
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
guard let location else { return nil }
|
||||
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
|
||||
}
|
||||
|
||||
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let bookmarkCFI = bookmark.location.cfi,
|
||||
let locationCFI = location.cfi {
|
||||
return bookmarkCFI == locationCFI
|
||||
}
|
||||
|
||||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||||
let locationAnchor = location.rangeAnchor {
|
||||
return bookmarkAnchor == locationAnchor
|
||||
}
|
||||
|
||||
if let bookmarkFragment = bookmark.location.fragment,
|
||||
let locationFragment = location.fragment {
|
||||
return bookmarkFragment == locationFragment
|
||||
}
|
||||
|
||||
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
|
||||
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
|
||||
return progressionDelta <= threshold
|
||||
}
|
||||
|
||||
private func bookmarkHref(for location: RDEPUBLocation) -> String {
|
||||
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
|
||||
}
|
||||
|
||||
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
|
||||
let rawHref = href.components(separatedBy: "#").first ?? href
|
||||
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
||||
}
|
||||
|
||||
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
|
||||
guard let controller else { return nil }
|
||||
guard let location else { return nil }
|
||||
guard let publication = controller.publication else {
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedBookmarkNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
|
||||
setupReaderView(readerView, in: controller.view)
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
}
|
||||
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
|
||||
let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
|
||||
if let id = context.currentBookIdentifier {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDEpubReaderView, in containerView: UIView) {
|
||||
readerView.pageProvider = context.controller
|
||||
readerView.delegate = context.controller
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
NSLayoutConstraint.activate([
|
||||
readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
|
||||
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
|
||||
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
|
||||
readerView.register(
|
||||
contentView: RDEPUBTrialWallContainerView.self,
|
||||
contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTrialWallContainerView.self)
|
||||
)
|
||||
context.controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(loadingIndicator)
|
||||
NSLayoutConstraint.activate([
|
||||
loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
|
||||
errorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(errorLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
|
||||
errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
|
||||
errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
|
||||
context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||
let toolView: RDEPUBReaderTopToolViewProviding =
|
||||
context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onSearch = { [weak self] in
|
||||
self?.toggleSearchBar()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
_ = self?.context.runtime?.toggleBookmark()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||
let toolView: RDEPUBReaderBottomToolViewProviding =
|
||||
context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
self?.presentTableOfContents()
|
||||
}
|
||||
toolView.onShowBookmarks = { [weak self] in
|
||||
self?.context.runtime?.presentBookmarksManager()
|
||||
}
|
||||
toolView.onShowHighlights = { [weak self] in
|
||||
self?.context.runtime?.presentHighlightsManager()
|
||||
}
|
||||
toolView.onAddHighlight = { [weak self] in
|
||||
self?.context.runtime?.presentAnnotationCreation()
|
||||
}
|
||||
toolView.onShowSettings = { [weak self] in
|
||||
self?.presentSettings()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
let uiState = makeUIState()
|
||||
applyUIState(uiState)
|
||||
updateSearchBar()
|
||||
}
|
||||
|
||||
func makeUIState() -> RDEPUBReaderUIState {
|
||||
guard let controller else { return .empty }
|
||||
return RDEPUBReaderUIState(
|
||||
canToggleBookmark: controller.currentBookIdentifier != nil,
|
||||
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
||||
canShowBookmarks: !controller.activeBookmarks.isEmpty,
|
||||
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
|
||||
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
|
||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||
allowsHighlights: controller.configuration.allowsHighlights,
|
||||
showsSettingsPanel: controller.configuration.showsSettingsPanel
|
||||
)
|
||||
}
|
||||
|
||||
func applyUIState(_ state: RDEPUBReaderUIState) {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
controller.topToolView.setTitle(
|
||||
controller.title
|
||||
?? controller.parser?.metadata.title
|
||||
?? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
)
|
||||
controller.topToolView.setBookmarkEnabled(state.canToggleBookmark)
|
||||
controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation)
|
||||
controller.bottomToolView.apply(theme: controller.configuration.theme)
|
||||
controller.bottomToolView.updateVisibility(
|
||||
showsTableOfContents: state.showsTableOfContents,
|
||||
allowsHighlights: state.allowsHighlights,
|
||||
showsSettingsPanel: state.showsSettingsPanel
|
||||
)
|
||||
controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks)
|
||||
controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight)
|
||||
controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
|
||||
}
|
||||
|
||||
private func hasBookmarkAtCurrentLocation() -> Bool {
|
||||
guard let controller else { return false }
|
||||
return context.runtime?.annotationCoordinator.currentBookmark() != nil
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
|
||||
context.runtime?.settingsPanelWillAppear()
|
||||
|
||||
let settingsController = RDEPUBReaderSettingsViewController(
|
||||
configuration: controller.configuration,
|
||||
brightness: controller.currentBrightness
|
||||
)
|
||||
settingsController.onBrightnessChange = { [weak controller] brightness in
|
||||
controller?.setScreenBrightness(brightness)
|
||||
}
|
||||
settingsController.onFontSizeChange = { [weak controller] fontSize in
|
||||
controller?.updateConfiguration { $0.fontSize = fontSize }
|
||||
}
|
||||
settingsController.onFontChoiceChange = { [weak controller] fontChoice in
|
||||
controller?.updateConfiguration { $0.fontChoice = fontChoice }
|
||||
}
|
||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
||||
}
|
||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||
controller?.updateConfiguration { $0.displayType = displayType }
|
||||
}
|
||||
settingsController.onThemeChange = { [weak controller] theme in
|
||||
controller?.updateConfiguration { $0.theme = theme }
|
||||
}
|
||||
settingsController.onDismiss = { [weak self] in
|
||||
|
||||
self?.context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
navigationController.presentationController?.delegate = self
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
let items = controller.flattenedTableOfContentsItems(
|
||||
from: controller.publication?.tableOfContents ?? [],
|
||||
includePageNumbers: false
|
||||
)
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let chapterController = RDEPUBReaderChapterListController(
|
||||
items: items,
|
||||
currentItem: controller.currentTableOfContentsItem,
|
||||
theme: controller.configuration.theme
|
||||
)
|
||||
chapterController.onSelectItem = { [weak controller] item in
|
||||
guard let controller else { return }
|
||||
chapterController.dismiss(animated: true) {
|
||||
_ = controller.go(toTableOfContentsItem: item, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: chapterController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func toggleSearchBar() {
|
||||
guard let controller else { return }
|
||||
if controller.isSearchBarVisible {
|
||||
controller.hideSearchBar()
|
||||
} else {
|
||||
controller.showSearchBar()
|
||||
}
|
||||
}
|
||||
|
||||
func updateSearchBar() {
|
||||
guard let controller else { return }
|
||||
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||
if let searchState = controller.searchState {
|
||||
if let index = searchState.currentMatchIndex {
|
||||
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
controller.searchBarView.showNoResults()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
}
|
||||
|
||||
private func close(_ controller: UIViewController) {
|
||||
let target = closestDismissTarget(from: controller)
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.viewControllers.first !== target {
|
||||
navigationController.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.presentingViewController != nil {
|
||||
navigationController.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if target.presentingViewController != nil {
|
||||
target.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
controller.dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
|
||||
var candidate: UIViewController = controller
|
||||
var current = controller.parent
|
||||
while let parent = current {
|
||||
if let navigationController = parent.navigationController,
|
||||
navigationController.viewControllers.contains(parent) {
|
||||
return parent
|
||||
}
|
||||
if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
|
||||
return parent
|
||||
}
|
||||
candidate = parent
|
||||
current = parent.parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import UIKit
|
||||
|
||||
/// Captures layout parameters on the main thread for safe use on background queues.
|
||||
/// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread.
|
||||
struct RDEPUBLayoutSnapshot {
|
||||
let pageSize: CGSize
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
let renderSignature: String
|
||||
}
|
||||
|
||||
final class RDEPUBReaderContext {
|
||||
|
||||
private let activityLock = NSLock()
|
||||
|
||||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||||
|
||||
weak var controller: RDEPUBReaderController?
|
||||
|
||||
weak var readerView: RDEpubReaderView?
|
||||
|
||||
let state: RDEPUBReaderState
|
||||
|
||||
let environment: RDEPUBReaderEnvironment
|
||||
|
||||
let services: RDEPUBReaderServices
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies {
|
||||
get { services.dependencies }
|
||||
set {
|
||||
services.dependencies = newValue
|
||||
environment.displayEnvironment = newValue.environment
|
||||
}
|
||||
}
|
||||
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
var parser: RDEPUBParser? {
|
||||
get { state.parser }
|
||||
set { state.parser = newValue }
|
||||
}
|
||||
|
||||
var publication: RDEPUBPublication? {
|
||||
get { state.publication }
|
||||
set { state.publication = newValue }
|
||||
}
|
||||
|
||||
var readingSession: RDEPUBReadingSession? {
|
||||
get { state.readingSession }
|
||||
set { state.readingSession = newValue }
|
||||
}
|
||||
|
||||
var textBook: RDEPUBTextBook? {
|
||||
get { state.textBook }
|
||||
set { state.textBook = newValue }
|
||||
}
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap? {
|
||||
get { state.bookPageMap }
|
||||
set { state.bookPageMap = newValue }
|
||||
}
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] {
|
||||
get { state.activeBookmarks }
|
||||
set { state.activeBookmarks = newValue }
|
||||
}
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] {
|
||||
get { state.activeHighlights }
|
||||
set { state.activeHighlights = newValue }
|
||||
}
|
||||
|
||||
var currentBookIdentifier: String? {
|
||||
get { state.currentBookIdentifier }
|
||||
set { state.currentBookIdentifier = newValue }
|
||||
}
|
||||
|
||||
var paginationToken: UUID {
|
||||
get { state.paginationToken }
|
||||
set { state.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { state.searchState }
|
||||
set { state.searchState = newValue }
|
||||
}
|
||||
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] {
|
||||
get { state.pendingPageMapUpdates }
|
||||
set { state.pendingPageMapUpdates = newValue }
|
||||
}
|
||||
|
||||
var lastTextPaginationPageSize: CGSize? {
|
||||
get { state.lastTextPaginationPageSize }
|
||||
set { state.lastTextPaginationPageSize = newValue }
|
||||
}
|
||||
|
||||
var lastMetadataParseWallClockMs: Int {
|
||||
get { state.lastMetadataParseWallClockMs }
|
||||
set { state.lastMetadataParseWallClockMs = newValue }
|
||||
}
|
||||
|
||||
var lastMetadataParseConcurrency: Int {
|
||||
get { state.lastMetadataParseConcurrency }
|
||||
set { state.lastMetadataParseConcurrency = newValue }
|
||||
}
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { state.currentSelection }
|
||||
set { state.currentSelection = newValue }
|
||||
}
|
||||
|
||||
var selectionState: RDEPUBSelectionState {
|
||||
get { state.selectionState }
|
||||
set { state.selectionState = newValue }
|
||||
}
|
||||
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
|
||||
var isRepaginating: Bool {
|
||||
get { state.isRepaginating }
|
||||
set { state.isRepaginating = newValue }
|
||||
}
|
||||
|
||||
var didStartInitialLoad: Bool {
|
||||
get { state.didStartInitialLoad }
|
||||
set { state.didStartInitialLoad = newValue }
|
||||
}
|
||||
|
||||
var isExternalTextBook: Bool {
|
||||
get { state.isExternalTextBook }
|
||||
set { state.isExternalTextBook = newValue }
|
||||
}
|
||||
|
||||
var textFileURL: URL? {
|
||||
get { state.textFileURL }
|
||||
set { state.textFileURL = newValue }
|
||||
}
|
||||
|
||||
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
self.readerView = controller.readerView
|
||||
let state = RDEPUBReaderState()
|
||||
self.state = state
|
||||
self.environment = RDEPUBReaderEnvironment(
|
||||
controller: controller,
|
||||
readerView: controller.readerView,
|
||||
displayEnvironment: RDEPUBUIScreenEnvironment()
|
||||
)
|
||||
self.services = RDEPUBReaderServices(dependencies: .live)
|
||||
}
|
||||
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
environment.currentLayoutContext(configuration: configuration)
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
||||
}
|
||||
|
||||
/// Captures all layout parameters needed for background chapter loading.
|
||||
/// Must be called on the main thread. The returned snapshot is safe to use on any thread.
|
||||
func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let pageSize = currentTextPageSize()
|
||||
let style = currentTextRenderStyle()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
|
||||
return RDEPUBLayoutSnapshot(
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
renderSignature: renderSignature
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextPageSize() -> CGSize {
|
||||
// M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too.
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||||
return resolvedSize
|
||||
}
|
||||
}
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
if viewportSize.width > 0, viewportSize.height > 0 {
|
||||
return viewportSize
|
||||
}
|
||||
if let lastTextPaginationPageSize,
|
||||
lastTextPaginationPageSize.width > 0,
|
||||
lastTextPaginationPageSize.height > 0 {
|
||||
return lastTextPaginationPageSize
|
||||
}
|
||||
return environment.fallbackViewportSize
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return environment.currentTextRenderStyle(configuration: configuration)
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
services.resolvedTextRenderer(configuration: configuration)
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
state.activePages
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
state.activeChapters
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { environment.currentBrightness }
|
||||
set { environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
state.replaceActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
state.clearActiveSnapshot()
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
services.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
services.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
services.makeTextBookBuilder(
|
||||
configuration: configuration,
|
||||
cache: textBookCache,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
|
||||
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let contentHash: String
|
||||
if let parser,
|
||||
let publication,
|
||||
publication.spine.indices.contains(spineIndex) {
|
||||
let href = publication.spine[spineIndex].href
|
||||
contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? ""
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
return chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||
chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedContentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
func chapterCacheKey(
|
||||
forSpineIndex spineIndex: Int,
|
||||
precomputedContentHash: String,
|
||||
renderSignature: String
|
||||
) -> RDEPUBChapterCacheKey {
|
||||
RDEPUBChapterCacheKey(
|
||||
bookID: currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: precomputedContentHash
|
||||
)
|
||||
}
|
||||
|
||||
func currentRenderSignature() -> String {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig)
|
||||
}
|
||||
|
||||
private func renderSignature(
|
||||
style: RDEPUBTextRenderStyle,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> String {
|
||||
[
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||
}
|
||||
|
||||
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return publication.spine.firstIndex {
|
||||
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder {
|
||||
services.makePlainTextBookBuilder(
|
||||
configuration: configuration,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
controller?.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let currentBookIdentifier else { return nil }
|
||||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let currentBookIdentifier else { return }
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func markUserNavigationActivity() {
|
||||
activityLock.lock()
|
||||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||||
activityLock.unlock()
|
||||
}
|
||||
|
||||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||||
activityLock.lock()
|
||||
let timestamp = lastUserNavigationTimestamp
|
||||
activityLock.unlock()
|
||||
guard timestamp > 0 else { return .greatestFiniteMagnitude }
|
||||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||||
}
|
||||
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook else { return nil }
|
||||
// External text books (e.g. plain .txt) have no publication; their
|
||||
// chapter hrefs are matched verbatim.
|
||||
let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href
|
||||
return textBook.chapters.lazy
|
||||
.first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
func showLoading() {
|
||||
controller?.showLoading()
|
||||
}
|
||||
|
||||
func hideLoading() {
|
||||
controller?.hideLoading()
|
||||
}
|
||||
|
||||
func handle(error: Error) {
|
||||
controller?.handle(error: error)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
controller?.updateReaderChrome()
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
controller?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
controller?.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
|
||||
var currentBrightness: CGFloat { get set }
|
||||
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
public var currentBrightness: CGFloat {
|
||||
get { CGFloat(UIScreen.main.brightness) }
|
||||
set { UIScreen.main.brightness = newValue }
|
||||
}
|
||||
|
||||
public var fallbackViewportSize: CGSize {
|
||||
UIScreen.main.bounds.size
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderDependencies {
|
||||
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
|
||||
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
/// 自定义顶部工具栏工厂;nil 时使用内置 `RDEPUBReaderTopToolView`
|
||||
public var makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)?
|
||||
|
||||
/// 自定义底部工具栏工厂;nil 时使用内置 `RDEPUBReaderBottomToolView`
|
||||
public var makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)?
|
||||
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
makePaginator: @escaping () -> RDEPUBPaginator,
|
||||
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
||||
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder,
|
||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer,
|
||||
makeTopToolView: (() -> RDEPUBReaderTopToolViewProviding)? = nil,
|
||||
makeBottomToolView: (() -> RDEPUBReaderBottomToolViewProviding)? = nil
|
||||
) {
|
||||
self.environment = environment
|
||||
self.makeParser = makeParser
|
||||
self.makePaginator = makePaginator
|
||||
self.makeTextBookBuilder = makeTextBookBuilder
|
||||
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
self.makeTopToolView = makeTopToolView
|
||||
self.makeBottomToolView = makeBottomToolView
|
||||
}
|
||||
|
||||
/// 构建带加密资源 provider 的默认依赖:
|
||||
/// 宿主实现 `RDEPUBResourceDataProvider` 后经此注入,即可打开单文件加密的 EPUB。
|
||||
/// - Parameter resourceDataProvider: 解密数据提供者;传 nil 等价于 `.live`
|
||||
public static func live(resourceDataProvider: RDEPUBResourceDataProvider?) -> RDEPUBReaderDependencies {
|
||||
var dependencies = RDEPUBReaderDependencies.live
|
||||
guard let resourceDataProvider else { return dependencies }
|
||||
dependencies.makeParser = {
|
||||
let parser = RDEPUBParser()
|
||||
parser.resourceDataProvider = resourceDataProvider
|
||||
return parser
|
||||
}
|
||||
return dependencies
|
||||
}
|
||||
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
makeParser: { RDEPUBParser() },
|
||||
makePaginator: { RDEPUBPaginator() },
|
||||
makeTextBookBuilder: { renderer, cache, layoutConfig in
|
||||
RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
|
||||
},
|
||||
makePlainTextBookBuilder: { renderer, layoutConfig in
|
||||
RDEpubPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
|
||||
},
|
||||
makeTextRenderer: { engine in
|
||||
switch engine {
|
||||
case .dtCoreText:
|
||||
return RDEPUBDTCoreTextRenderer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import UIKit
|
||||
|
||||
enum RDEPUBTextPageLayoutMetrics {
|
||||
|
||||
static let pageNumberTrailingPadding: CGFloat = 4
|
||||
|
||||
static let pageNumberFooterPadding: CGFloat = 8
|
||||
|
||||
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||||
|
||||
static func contentInsets(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
safeAreaInsets: UIEdgeInsets
|
||||
) -> UIEdgeInsets {
|
||||
let configInsets = configuration.reflowableContentInsets
|
||||
return UIEdgeInsets(
|
||||
top: max(configInsets.top, safeAreaInsets.top),
|
||||
left: max(configInsets.left, safeAreaInsets.left),
|
||||
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||
right: max(configInsets.right, safeAreaInsets.right)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBReaderEnvironment {
|
||||
|
||||
weak var controller: RDEPUBReaderController?
|
||||
|
||||
weak var readerView: RDEpubReaderView?
|
||||
|
||||
var displayEnvironment: any RDEPUBReaderDisplayEnvironment
|
||||
|
||||
init(
|
||||
controller: RDEPUBReaderController,
|
||||
readerView: RDEpubReaderView,
|
||||
displayEnvironment: any RDEPUBReaderDisplayEnvironment
|
||||
) {
|
||||
self.controller = controller
|
||||
self.readerView = readerView
|
||||
self.displayEnvironment = displayEnvironment
|
||||
}
|
||||
|
||||
func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> RDEPUBNavigatorLayoutContext {
|
||||
let containerSize = readerView?.bounds.size ?? .zero
|
||||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||||
let resolvedSize = containerSize == .zero ? viewSize : containerSize
|
||||
return RDEPUBNavigatorLayoutContext(
|
||||
containerSize: resolvedSize,
|
||||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||||
safeAreaInsets: RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets),
|
||||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||||
reflowableContentInsets: configuration.reflowableContentInsets
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderStyle {
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
pageSize: CGSize
|
||||
) -> RDEPUBTextLayoutConfig {
|
||||
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
frameHeight: max(pageSize.height, 1),
|
||||
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||
configuration: configuration,
|
||||
safeAreaInsets: safeAreaInsets
|
||||
),
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85,
|
||||
fallbackViewportSize: displayEnvironment.fallbackViewportSize
|
||||
)
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { displayEnvironment.currentBrightness }
|
||||
set { displayEnvironment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
var fallbackViewportSize: CGSize {
|
||||
displayEnvironment.fallbackViewportSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let context, let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
!controller.didStartInitialLoad,
|
||||
readerView.bounds.width > 0,
|
||||
readerView.bounds.height > 0 else {
|
||||
return
|
||||
}
|
||||
controller.didStartInitialLoad = true
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
guard let context, let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
let loadToken = UUID()
|
||||
context.paginationToken = loadToken
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self, let context = self.context, let controller = context.controller else { return }
|
||||
let parser = context.makeParser()
|
||||
|
||||
do {
|
||||
try parser.parse(epubURL: controller.epubURL)
|
||||
let publication = parser.makePublication()
|
||||
let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
|
||||
let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
|
||||
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
|
||||
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.paginationToken == loadToken else { return }
|
||||
context.runtime?.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let context = self.context else { return }
|
||||
guard context.paginationToken == loadToken else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
guard let context, let controller = context.controller else { return }
|
||||
context.parser = parser
|
||||
context.publication = publication
|
||||
context.currentBookIdentifier = bookIdentifier
|
||||
context.activeBookmarks = bookmarks
|
||||
context.activeHighlights = highlights
|
||||
context.readingSession = RDEPUBReadingSession(publication: publication)
|
||||
controller.title = parser.metadata.title.isEmpty
|
||||
? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
: parser.metadata.title
|
||||
controller.applyReaderViewConfiguration()
|
||||
context.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didOpen: publication)
|
||||
controller.applyOrientationLockIfNeeded()
|
||||
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastPageChangeSpineIndex: Int?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
animated: Bool = false,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return false }
|
||||
if context.bookPageMap != nil {
|
||||
_ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
|
||||
}
|
||||
guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
return false
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil {
|
||||
guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
|
||||
return false
|
||||
}
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else {
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
|
||||
recordPageChangeIfNeeded()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
return nil
|
||||
}
|
||||
let pageNumber = readerView.currentPage + 1
|
||||
if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
|
||||
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
|
||||
return location
|
||||
}
|
||||
|
||||
if let readingSession = context.readingSession,
|
||||
readingSession.activePages.indices.contains(readerView.currentPage) {
|
||||
return readingSession.fallbackLocation(
|
||||
for: readingSession.activePages[readerView.currentPage],
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
return nil
|
||||
}
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateLocation: location)
|
||||
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
|
||||
controller.updateReaderChrome()
|
||||
}
|
||||
|
||||
func recordPageChangeIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
let currentPageNumber = readerView.currentPage + 1
|
||||
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastSpineIndex = lastPageChangeSpineIndex,
|
||||
lastSpineIndex != currentSpineIndex {
|
||||
runtime.jumpSessionManager.recordPageChange(
|
||||
fromSpineIndex: lastSpineIndex,
|
||||
toSpineIndex: currentSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
lastPageChangeSpineIndex = currentSpineIndex
|
||||
|
||||
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
|
||||
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
isIdle: isIdle
|
||||
) {
|
||||
runtime.jumpSessionManager.endSession(endReason)
|
||||
}
|
||||
}
|
||||
|
||||
func resetPageChangeState() {
|
||||
lastPageChangeSpineIndex = nil
|
||||
}
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let metadataParseControlLock = NSLock()
|
||||
|
||||
private var activeMetadataParseCancellationController: RDEPUBMetadataParseCancellationController?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
let publication = context.publication,
|
||||
let readingSession = context.readingSession else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.isRepaginating = true
|
||||
controller.errorLabel.isHidden = true
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation,
|
||||
token: token
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
return
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
hostingView: controller.ensurePaginationHostView(),
|
||||
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
|
||||
) { [weak controller] pageCounts in
|
||||
guard let controller, self.context.paginationToken == token else { return }
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: pageCounts,
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !textBook.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
controller.isRepaginating = false
|
||||
controller.hideLoading()
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
}
|
||||
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation(
|
||||
preferredRestoreLocation: RDEPUBLocation? = nil
|
||||
) {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = preferredRestoreLocation
|
||||
?? context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
?? context.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
// Chapter pages, page counts, partial maps, and background coverage all
|
||||
// depend on the viewport. Keeping them across a rotation can pair the
|
||||
// new page map with chapters paginated for the old size, leaving an
|
||||
// on-demand page in its loading state forever.
|
||||
context.runtime?.prepareForFullRepagination()
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
if readerView.isPageCurlTransitioning {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
return
|
||||
}
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
if readerView.currentDisplayType == .pageCurl, readerView.currentPage >= 0 {
|
||||
readerView.transitionToPage(pageNum: readerView.currentPage, animated: false)
|
||||
} else {
|
||||
readerView.reloadData()
|
||||
}
|
||||
if let restoreLocation {
|
||||
_ = context.restoreReadingLocation(restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
let style = controller.currentTextRenderStyle()
|
||||
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
|
||||
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
let context = self.context
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutSnapshot = context.makeLayoutSnapshot()
|
||||
let runtime = context.runtime
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
guard let runtime else { return }
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
guard prioritizedCandidates.first != nil else {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: prioritizedCandidates,
|
||||
runtime: runtime,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Pagination",
|
||||
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||
)
|
||||
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: runtimeChapter,
|
||||
publication: publication,
|
||||
runtime: runtime,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Pagination",
|
||||
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
runtime.chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: initialChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
runtime.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
let cancellationController = self.beginMetadataParseCancellationController(for: token)
|
||||
let worker = RDEPUBMetadataParseWorker(
|
||||
context: context,
|
||||
cancellationController: cancellationController,
|
||||
token: token,
|
||||
parser: parser,
|
||||
publication: publication
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Pagination",
|
||||
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
|
||||
)
|
||||
worker.start(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Pagination",
|
||||
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: [Int],
|
||||
runtime: RDEPUBReaderRuntime,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
var lastError: Error?
|
||||
for spineIndex in prioritizedSpineIndices {
|
||||
do {
|
||||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
private func loadInitialInteractiveRuntimeChapters(
|
||||
anchorChapter: RDEPUBRuntimeChapter,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let minimumInteractivePageCount = 2
|
||||
let maximumAdditionalChapters = 1
|
||||
|
||||
guard anchorChapter.pages.count < minimumInteractivePageCount else {
|
||||
return [anchorChapter]
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorChapter.spineIndex) else {
|
||||
return [anchorChapter]
|
||||
}
|
||||
|
||||
var selectedChapters: [RDEPUBRuntimeChapter] = [anchorChapter]
|
||||
|
||||
for offset in 1...maximumAdditionalChapters {
|
||||
let candidatePositions = [anchorPosition + offset, anchorPosition - offset]
|
||||
for candidatePosition in candidatePositions {
|
||||
guard buildableSpineIndices.indices.contains(candidatePosition) else { continue }
|
||||
let spineIndex = buildableSpineIndices[candidatePosition]
|
||||
guard selectedChapters.contains(where: { $0.spineIndex == spineIndex }) == false else { continue }
|
||||
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
selectedChapters.append(chapter)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
let loadedPageCount = selectedChapters.reduce(0) { $0 + $1.pages.count }
|
||||
if loadedPageCount >= minimumInteractivePageCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selectedChapters
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
builder.add(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func prioritizedBuildableSpineIndices(
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) -> [Int] {
|
||||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||||
return publication.spine.indices
|
||||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
.sorted { lhs, rhs in
|
||||
abs(lhs - preferred) < abs(rhs - preferred)
|
||||
}
|
||||
}
|
||||
|
||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
func cancelActiveMetadataParseWork() {
|
||||
metadataParseControlLock.lock()
|
||||
let controller = activeMetadataParseCancellationController
|
||||
activeMetadataParseCancellationController = nil
|
||||
metadataParseControlLock.unlock()
|
||||
controller?.cancel()
|
||||
}
|
||||
|
||||
func finishMetadataParseCancellationController(_ controller: RDEPUBMetadataParseCancellationController) {
|
||||
metadataParseControlLock.lock()
|
||||
if activeMetadataParseCancellationController === controller {
|
||||
activeMetadataParseCancellationController = nil
|
||||
}
|
||||
metadataParseControlLock.unlock()
|
||||
}
|
||||
|
||||
private func beginMetadataParseCancellationController(for token: UUID) -> RDEPUBMetadataParseCancellationController {
|
||||
let controller = RDEPUBMetadataParseCancellationController(token: token)
|
||||
metadataParseControlLock.lock()
|
||||
let previous = activeMetadataParseCancellationController
|
||||
activeMetadataParseCancellationController = controller
|
||||
metadataParseControlLock.unlock()
|
||||
previous?.cancel()
|
||||
return controller
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
|
||||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||||
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
|
||||
self?.handleDeferredCFIMapReady(for: spineIndex)
|
||||
}
|
||||
return loader
|
||||
}()
|
||||
|
||||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
|
||||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||||
|
||||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||||
|
||||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||||
|
||||
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
|
||||
|
||||
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
|
||||
|
||||
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
|
||||
|
||||
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
|
||||
|
||||
lazy var presentationRuntime = RDEPUBPresentationRuntime(
|
||||
context: context,
|
||||
locationCoordinator: locationCoordinator,
|
||||
jumpSessionManager: jumpSessionManager,
|
||||
reconciliationCoordinator: reconciliationCoordinator
|
||||
)
|
||||
|
||||
lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
|
||||
context: context,
|
||||
store: chapterRuntimeStore,
|
||||
loader: chapterLoader,
|
||||
presentationRuntime: presentationRuntime,
|
||||
locationCoordinator: locationCoordinator,
|
||||
backgroundPriorityManager: backgroundPriorityManager,
|
||||
jumpSessionManager: jumpSessionManager,
|
||||
refreshVisibleContentPreservingLocation: { [weak self] in
|
||||
self?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
)
|
||||
|
||||
var isSettingsPanelOpen: Bool = false
|
||||
|
||||
var needsFullRepaginationAfterSettingsClose: Bool = false
|
||||
|
||||
private var settingsPreviewGeneration: Int = 0
|
||||
|
||||
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
||||
|
||||
/// The location before the settings preview replaces the full book page map
|
||||
/// with the current chapter's temporary map. The temporary map rebases that
|
||||
/// chapter at page zero, so it must never be used as the final restore source.
|
||||
private var settingsRestoreLocation: RDEPUBLocation?
|
||||
|
||||
/// A settings session must keep one immutable text offset. Re-capturing the
|
||||
/// start of each preview page makes repeated font-size changes drift backward.
|
||||
private var settingsPreviewAnchor: SettingsPreviewAnchor?
|
||||
|
||||
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
||||
|
||||
private struct SettingsPreviewAnchor {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let offset: Int
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolViewProviding {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
context.parser = nil
|
||||
context.publication = nil
|
||||
context.clearActiveSnapshot()
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
clearOnDemandPageModeState()
|
||||
viewportMonitor.resetForReload()
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard context.controller != nil,
|
||||
let readerView = context.readerView,
|
||||
pageNumber > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let textBook = context.textBook {
|
||||
guard textBook.page(at: pageNumber) != nil else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil {
|
||||
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.bookmark(withID: id)
|
||||
}
|
||||
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.highlight(withID: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
func removeAllHighlights() {
|
||||
annotationCoordinator.removeAllHighlights()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
annotationCoordinator.presentBookmarksManager()
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
annotationCoordinator.presentHighlightsManager()
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
annotationCoordinator.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
searchCoordinator.selectSearchMatch(at: index)
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
loadCoordinator.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
|
||||
presentationRuntime.applyBookPageMap(
|
||||
bookPageMap,
|
||||
restoreLocation: restoreLocation
|
||||
) { [weak self] restoreLocation in
|
||||
self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
|
||||
}
|
||||
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
|
||||
if isSettingsPanelOpen {
|
||||
needsFullRepaginationAfterSettingsClose = true
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
scheduleSettingsPreviewRepagination()
|
||||
} else {
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSettingsPreviewRepagination() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
settingsPreviewGeneration += 1
|
||||
let previewGeneration = settingsPreviewGeneration
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self,
|
||||
self.isSettingsPanelOpen,
|
||||
previewGeneration == self.settingsPreviewGeneration else {
|
||||
return
|
||||
}
|
||||
self.pendingSettingsPreviewWorkItem = nil
|
||||
let previewAnchor = self.settingsPreviewAnchor
|
||||
?? self.captureSettingsPreviewAnchor()
|
||||
self.chapterRuntimeStore.invalidateAllLayoutDependentContent()
|
||||
self.repaginateCurrentChapterOnly(
|
||||
previewGeneration: previewGeneration,
|
||||
previewAnchor: previewAnchor
|
||||
)
|
||||
}
|
||||
pendingSettingsPreviewWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + settingsPreviewDebounceDelay,
|
||||
execute: workItem
|
||||
)
|
||||
}
|
||||
|
||||
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return nil }
|
||||
|
||||
let absolutePageIndex = readerView.currentPage
|
||||
guard absolutePageIndex >= 0,
|
||||
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
chapter.pages.indices.contains(localPageIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let page = chapter.pages[localPageIndex]
|
||||
let offset = page.contentRange.length > 0
|
||||
? page.contentRange.location
|
||||
: page.pageStartOffset
|
||||
return SettingsPreviewAnchor(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
offset: offset
|
||||
)
|
||||
}
|
||||
|
||||
private func repaginateCurrentChapterOnly(
|
||||
previewGeneration: Int,
|
||||
previewAnchor: SettingsPreviewAnchor?
|
||||
) {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
let currentPageNumber = readerView.currentPage + 1
|
||||
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
|
||||
return
|
||||
}
|
||||
let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: currentSpineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .preview
|
||||
) { [weak self] result in
|
||||
guard let self,
|
||||
self.isSettingsPanelOpen,
|
||||
previewGeneration == self.settingsPreviewGeneration,
|
||||
let readerView = self.context.readerView else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
let partialMap = self.makePartialPageMap(from: [chapter])
|
||||
self.presentationRuntime.applySettingsPreviewPageMap(partialMap)
|
||||
readerView.reloadData()
|
||||
|
||||
if let targetPage = self.settingsPreviewTargetPage(
|
||||
in: chapter,
|
||||
for: previewAnchor
|
||||
) {
|
||||
readerView.transitionToPage(pageNum: targetPage, animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if let previewLocation,
|
||||
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
|
||||
return
|
||||
}
|
||||
readerView.transitionToPage(pageNum: 0, animated: false)
|
||||
|
||||
case .failure(let error):
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsPreviewTargetPage(
|
||||
in chapter: RDEPUBRuntimeChapter,
|
||||
for anchor: SettingsPreviewAnchor?
|
||||
) -> Int? {
|
||||
guard let anchor,
|
||||
anchor.spineIndex == chapter.spineIndex,
|
||||
anchor.href == chapter.href,
|
||||
!chapter.pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let exactPage = chapter.pages.first(where: { page in
|
||||
let lowerBound = page.contentRange.location
|
||||
let upperBound = page.contentRange.location + page.contentRange.length
|
||||
if page.contentRange.length == 0 {
|
||||
return anchor.offset == lowerBound
|
||||
}
|
||||
return anchor.offset >= lowerBound && anchor.offset < upperBound
|
||||
}) {
|
||||
return exactPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
if let nextPage = chapter.pages.first(where: { page in
|
||||
page.contentRange.location > anchor.offset
|
||||
}) {
|
||||
return nextPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
return max(chapter.pages.count - 1, 0)
|
||||
}
|
||||
|
||||
func settingsPanelWillAppear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
isSettingsPanelOpen = true
|
||||
needsFullRepaginationAfterSettingsClose = false
|
||||
let fallbackLocation = locationCoordinator.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
settingsPreviewAnchor = captureSettingsPreviewAnchor()
|
||||
settingsRestoreLocation = exactSettingsRestoreLocation(
|
||||
anchor: settingsPreviewAnchor,
|
||||
fallbackLocation: fallbackLocation
|
||||
)
|
||||
settingsPreviewGeneration += 1
|
||||
}
|
||||
|
||||
func settingsPanelDidDisappear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
isSettingsPanelOpen = false
|
||||
settingsPreviewGeneration += 1
|
||||
|
||||
if needsFullRepaginationAfterSettingsClose {
|
||||
needsFullRepaginationAfterSettingsClose = false
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation(
|
||||
preferredRestoreLocation: settingsRestoreLocation
|
||||
)
|
||||
}
|
||||
settingsRestoreLocation = nil
|
||||
settingsPreviewAnchor = nil
|
||||
}
|
||||
|
||||
private func exactSettingsRestoreLocation(
|
||||
anchor: SettingsPreviewAnchor?,
|
||||
fallbackLocation: RDEPUBLocation?
|
||||
) -> RDEPUBLocation? {
|
||||
guard let anchor else { return fallbackLocation }
|
||||
|
||||
let textAnchor = RDEPUBTextAnchor(
|
||||
fileIndex: anchor.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: anchor.offset,
|
||||
fragmentID: fallbackLocation?.fragment
|
||||
)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: fallbackLocation?.bookIdentifier ?? context.currentBookIdentifier,
|
||||
href: anchor.href,
|
||||
progression: fallbackLocation?.progression ?? 0,
|
||||
lastProgression: fallbackLocation?.lastProgression,
|
||||
fragment: fallbackLocation?.fragment,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: textAnchor, end: textAnchor)
|
||||
)
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
animated: Bool = false,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(
|
||||
location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
|
||||
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(
|
||||
forAbsolutePageNumber pageNumber: Int,
|
||||
allowSynchronousLoad: Bool = true,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
chapterWarmupOrchestrator.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNumber,
|
||||
allowSynchronousLoad: allowSynchronousLoad,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: Int,
|
||||
minimumTrailingPages: Int = 2,
|
||||
batchChapterCount: Int = 3
|
||||
) {
|
||||
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: currentPageNumber,
|
||||
minimumTrailingPages: minimumTrailingPages,
|
||||
batchChapterCount: batchChapterCount
|
||||
)
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount
|
||||
)
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
chapterRuntimeStore.invalidateAllLayoutDependentContent()
|
||||
context.bookPageMap = nil
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
jumpSessionManager.clearSession()
|
||||
backgroundPriorityManager.reset()
|
||||
backgroundCoverageStore.clearAll()
|
||||
chapterWarmupOrchestrator.clear()
|
||||
}
|
||||
|
||||
/// Clears every runtime value derived from the current viewport before a
|
||||
/// full repagination. The caller must capture the visible location first,
|
||||
/// because chapter/page resolution is intentionally invalid after this.
|
||||
func prepareForFullRepagination() {
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
chapterRuntimeStore.invalidateAllLayoutDependentContent()
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
jumpSessionManager.clearSession()
|
||||
backgroundPriorityManager.reset()
|
||||
backgroundCoverageStore.clearAll()
|
||||
chapterWarmupOrchestrator.clear()
|
||||
context.controller?.textDisplayCache.removeAll()
|
||||
}
|
||||
|
||||
func handleMemoryWarning() {
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let activeWindowIndices: Set<Int> = if let currentSpineIndex {
|
||||
[currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
|
||||
|
||||
backgroundCoverageStore.handleMemoryWarning(
|
||||
activeWindowSpineIndices: activeWindowIndices,
|
||||
protectedSpineIndices: protectedIndices
|
||||
)
|
||||
chapterRuntimeStore.handleMemoryWarning()
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
builder.add(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func handleDeferredCFIMapReady(for spineIndex: Int) {
|
||||
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
|
||||
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
return
|
||||
}
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
|
||||
|
||||
private let tokenLock = NSLock()
|
||||
private var _currentSearchToken: UUID = UUID()
|
||||
|
||||
private var currentSearchToken: UUID {
|
||||
get {
|
||||
tokenLock.lock()
|
||||
defer { tokenLock.unlock() }
|
||||
return _currentSearchToken
|
||||
}
|
||||
set {
|
||||
tokenLock.lock()
|
||||
_currentSearchToken = newValue
|
||||
tokenLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
|
||||
let token = UUID()
|
||||
currentSearchToken = token
|
||||
|
||||
let searchEngine = makeSearchEngine()
|
||||
let layoutSnapshot = context.makeLayoutSnapshot()
|
||||
|
||||
searchQueue.async { [weak self] in
|
||||
guard let self, self.currentSearchToken == token else { return }
|
||||
let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.currentSearchToken == token else { return }
|
||||
guard let controller = self.context.controller else { return }
|
||||
controller.searchState = RDEPUBSearchState(
|
||||
keyword: normalizedKeyword,
|
||||
matches: matches,
|
||||
currentMatchIndex: matches.isEmpty ? nil : 0
|
||||
)
|
||||
self.notifySearchStateChanged()
|
||||
if matches.isEmpty {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
} else {
|
||||
_ = self.navigateToCurrentSearchMatch(animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState,
|
||||
searchState.matches.indices.contains(index) else {
|
||||
return false
|
||||
}
|
||||
searchState.currentMatchIndex = index
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
currentSearchToken = UUID()
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
notifySearchStateChanged()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
let publication = controller.publication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageHrefs: [String]
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
} else if publication.spine.indices.contains(page.spineIndex) {
|
||||
pageHrefs = [
|
||||
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
|
||||
?? publication.spine[page.spineIndex].href
|
||||
]
|
||||
} else {
|
||||
pageHrefs = []
|
||||
}
|
||||
|
||||
guard !pageHrefs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentMatch = searchState.currentMatch
|
||||
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
let resources = pageHrefs.map { href in
|
||||
let matchCount = searchState.matches.filter {
|
||||
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
|
||||
}.count
|
||||
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
|
||||
return RDEPUBSearchPresentationResource(
|
||||
href: href,
|
||||
matchCount: matchCount,
|
||||
activeLocalMatchIndex: activeLocalMatchIndex
|
||||
)
|
||||
}
|
||||
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func makeSearchEngine() -> SearchEngineSnapshot? {
|
||||
guard let controller else { return nil }
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return .textBook(textBook, publication)
|
||||
}
|
||||
// External text books (e.g. plain .txt) carry a textBook but no
|
||||
// publication/parser/bookPageMap; search over the chapter text
|
||||
// directly using hrefs from the textBook itself.
|
||||
if let textBook = controller.textBook {
|
||||
return .externalTextBook(textBook)
|
||||
}
|
||||
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
|
||||
return .onDemand(publication)
|
||||
}
|
||||
if let parser = controller.parser, let publication = controller.publication {
|
||||
return .html(parser, publication)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
||||
guard let engine else { return [] }
|
||||
switch engine {
|
||||
case .textBook(let textBook, let publication):
|
||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||
case .externalTextBook(let textBook):
|
||||
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
||||
case .onDemand(let publication):
|
||||
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
|
||||
case .html(let parser, let publication):
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
}
|
||||
|
||||
private enum SearchEngineSnapshot {
|
||||
case textBook(RDEPUBTextBook, RDEPUBPublication)
|
||||
case externalTextBook(RDEPUBTextBook)
|
||||
case onDemand(RDEPUBPublication)
|
||||
case html(RDEPUBParser, RDEPUBPublication)
|
||||
}
|
||||
|
||||
// H-09: Each chapter iteration is wrapped in autoreleasepool to release
|
||||
// the chapter's typesetAttributedString memory between iterations.
|
||||
private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for spineIndex in buildableSpineIndices {
|
||||
let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
|
||||
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
|
||||
layoutSnapshot: layoutSnapshot
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
||||
let source = chapter.typesetAttributedString.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return [] }
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatches: [RDEPUBSearchMatch] = []
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
localMatches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: max(foundRange.length, 1),
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
return localMatches
|
||||
} // end autoreleasepool
|
||||
matches.append(contentsOf: chapterMatches)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func makeChapterData(
|
||||
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||
chapterIndex: Int
|
||||
) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
href: runtimeChapter.href,
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func advanceSearch(by delta: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentIndex = searchState.currentMatchIndex ?? 0
|
||||
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
|
||||
searchState.currentMatchIndex = nextIndex
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
private func notifySearchStateChanged() {
|
||||
guard let controller else { return }
|
||||
let state = controller.searchState
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
||||
}
|
||||
|
||||
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let searchMatch = controller.searchState?.currentMatch else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return false
|
||||
}
|
||||
|
||||
if let targetPageNumber = pageNumber(for: searchMatch),
|
||||
controller.readerView.currentPage == targetPageNumber - 1 {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return true
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
return controller.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let controller else { return nil }
|
||||
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
if let exactPageNumber = exactPageNumber(
|
||||
for: searchMatch,
|
||||
in: chapterData,
|
||||
keyword: controller.searchState?.keyword
|
||||
) {
|
||||
return exactPageNumber
|
||||
}
|
||||
|
||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||
return pageNumber
|
||||
}
|
||||
|
||||
if let rangeLocation = searchMatch.rangeLocation,
|
||||
let page = chapterData.page(containing: rangeLocation) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
if let publication = controller.publication {
|
||||
return textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
)
|
||||
}
|
||||
// External text books have no resolver; hrefs match verbatim.
|
||||
return textBook.chapterData(for: location.href)?.pageNumber(for: location)
|
||||
}
|
||||
|
||||
return controller.readingSession?.pageIndex(
|
||||
for: location,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
private func exactPageNumber(
|
||||
for searchMatch: RDEPUBSearchMatch,
|
||||
in chapterData: RDEPUBChapterData,
|
||||
keyword: String?
|
||||
) -> Int? {
|
||||
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalizedKeyword.isEmpty else { return nil }
|
||||
|
||||
let source = chapterData.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return nil }
|
||||
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
|
||||
if localMatchIndex == searchMatch.localMatchIndex,
|
||||
let page = chapterData.page(containing: foundRange.location) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderServices {
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies
|
||||
|
||||
init(dependencies: RDEPUBReaderDependencies) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
cache: RDEPUBTextBookCache?,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(
|
||||
resolvedTextRenderer(configuration: configuration),
|
||||
cache,
|
||||
layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) -> RDEpubPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(
|
||||
resolvedTextRenderer(configuration: configuration),
|
||||
layoutConfig
|
||||
)
|
||||
}
|
||||
|
||||
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
|
||||
let directory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import UIKit
|
||||
|
||||
/// M-01: All properties must be accessed exclusively from the main thread.
|
||||
/// This is currently enforced by convention — all verified access paths are main-thread-only.
|
||||
/// Adding @MainActor would formalize this but requires iOS 15+ and Swift concurrency throughout.
|
||||
/// For now, rely on the audit-verified access patterns and consider @MainActor in a future refactor.
|
||||
final class RDEPUBReaderState {
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
|
||||
var publication: RDEPUBPublication?
|
||||
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
|
||||
var textBook: RDEPUBTextBook?
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
var currentBookIdentifier: String?
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
|
||||
var didStartInitialLoad: Bool = false
|
||||
|
||||
var isExternalTextBook: Bool = false
|
||||
|
||||
var textFileURL: URL?
|
||||
|
||||
let textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
if let newValue, !newValue.isEmpty {
|
||||
selectionState = .selected(newValue)
|
||||
} else {
|
||||
selectionState = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBReaderUIState {
|
||||
|
||||
let canToggleBookmark: Bool
|
||||
|
||||
let hasBookmarkAtCurrentLocation: Bool
|
||||
|
||||
let canShowBookmarks: Bool
|
||||
|
||||
let canAddHighlight: Bool
|
||||
|
||||
let canShowHighlights: Bool
|
||||
|
||||
let showsTableOfContents: Bool
|
||||
|
||||
let allowsHighlights: Bool
|
||||
|
||||
let showsSettingsPanel: Bool
|
||||
}
|
||||
|
||||
extension RDEPUBReaderUIState {
|
||||
|
||||
static let empty = RDEPUBReaderUIState(
|
||||
canToggleBookmark: false,
|
||||
hasBookmarkAtCurrentLocation: false,
|
||||
canShowBookmarks: false,
|
||||
canAddHighlight: false,
|
||||
canShowHighlights: false,
|
||||
showsTableOfContents: true,
|
||||
allowsHighlights: true,
|
||||
showsSettingsPanel: true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
|
||||
if !controller.didStartInitialLoad {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
controller.startInitialLoadIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil || controller.isExternalTextBook else {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
return
|
||||
}
|
||||
|
||||
guard !isWaitingForViewportTransitionCompletion else {
|
||||
return
|
||||
}
|
||||
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
isWaitingForViewportTransitionCompletion = true
|
||||
|
||||
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||
guard let self, let controller = self.controller else { return }
|
||||
self.isWaitingForViewportTransitionCompletion = false
|
||||
controller.view.layoutIfNeeded()
|
||||
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
|
||||
}
|
||||
}
|
||||
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
pendingPresentationRestoreLocation = nil
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleViewportChangeIfNeeded(reason: pendingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
|
||||
let insets = controller.view.safeAreaInsets
|
||||
return RDEPUBViewportSignature(
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
safeTop: insets.top,
|
||||
safeLeft: insets.left,
|
||||
safeBottom: insets.bottom,
|
||||
safeRight: insets.right
|
||||
)
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad,
|
||||
let signature = viewportSignature ?? currentViewportSignature() else {
|
||||
return
|
||||
}
|
||||
|
||||
if controller.isRepaginating {
|
||||
pendingViewportChangeReason = reason
|
||||
return
|
||||
}
|
||||
|
||||
if let lastAppliedViewportSignature,
|
||||
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
|
||||
return
|
||||
}
|
||||
|
||||
lastAppliedViewportSignature = signature
|
||||
|
||||
if controller.isExternalTextBook {
|
||||
controller.rebuildExternalTextBook()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil else { return }
|
||||
controller.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
|
||||
case idle
|
||||
|
||||
case selecting(anchor: Int)
|
||||
|
||||
case selected(RDEPUBSelection)
|
||||
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||
|
||||
var hasSelection: Bool {
|
||||
switch self {
|
||||
case .idle:
|
||||
return false
|
||||
case .selecting, .selected, .committingAction:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var selection: RDEPUBSelection? {
|
||||
switch self {
|
||||
case .idle, .selecting:
|
||||
return nil
|
||||
case .selected(let selection), .committingAction(let selection, _):
|
||||
return selection
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user