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
|
||||
}
|
||||
Reference in New Issue
Block a user