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:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 deletions
@@ -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)")
}
}
@@ -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)
}
}
}
@@ -0,0 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -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)
}
}
@@ -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)"
}
}
}
@@ -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 }
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
)
}
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
}