feat: chapter runtime refactoring and related updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-26 18:50:07 +09:00
co-authored by Claude
parent b8aa10c535
commit 22e7e44220
123 changed files with 3701 additions and 9118 deletions
@@ -390,9 +390,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
if let location = fallbackLocation(for: effectivePageNum) {
persist(location: location)
}
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
readingSession?.transition(to: .idle)
}
runtime.locationCoordinator.recordPageChangeIfNeeded()
}
@@ -432,8 +429,9 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
guard currentSize.width > 0, currentSize.height > 0 else { return }
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
guard let previousPageSize = self.lastTextPaginationPageSize else { return }
let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
|| abs(currentSize.height - previousPageSize.height) > 0.5
guard stillChanged else { return }
self.repaginatePreservingCurrentLocation()
}
@@ -137,7 +137,6 @@ extension RDEPUBReaderController {
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
@@ -6,10 +6,12 @@ public final class RDEPUBReaderController: UIViewController {
public var configuration: RDEPUBReaderConfiguration {
didSet {
// M-09: Apply all configuration side effects even before view is loaded,
// but UI-dependent actions only after view is loaded.
readerContext.configuration = configuration
guard isViewLoaded else { return }
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
guard isViewLoaded else { return }
let oldConfiguration = oldValue
applyReaderViewConfiguration()
@@ -176,11 +178,6 @@ public final class RDEPUBReaderController: UIViewController {
set { readerContext.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { readerContext.paginator }
set { readerContext.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
@@ -291,6 +288,11 @@ public final class RDEPUBReaderController: UIViewController {
runtime.viewportMonitor.viewDidLayoutSubviews()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
runtime.handleMemoryWarning()
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
@@ -77,60 +77,104 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
return nil
}
return try? JSONDecoder().decode(RDEPUBLocation.self, from: data)
do {
return try JSONDecoder().decode(RDEPUBLocation.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode location for '\(bookIdentifier)': \(error)")
#endif
return nil
}
}
public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
guard let data = try? JSONEncoder().encode(location) else {
return
do {
let data = try JSONEncoder().encode(location)
defaults.set(data, forKey: locationPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode location for '\(bookIdentifier)': \(error)")
#endif
}
defaults.set(data, forKey: locationPrefix + bookIdentifier)
}
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
return []
}
return (try? JSONDecoder().decode([RDEPUBBookmark].self, from: data)) ?? []
do {
return try JSONDecoder().decode([RDEPUBBookmark].self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode bookmarks for '\(bookIdentifier)': \(error)")
#endif
return []
}
}
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
guard let data = try? JSONEncoder().encode(bookmarks) else {
return
do {
let data = try JSONEncoder().encode(bookmarks)
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
#endif
}
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
}
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
return []
}
return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? []
do {
return try JSONDecoder().decode([RDEPUBHighlight].self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode highlights for '\(bookIdentifier)': \(error)")
#endif
return []
}
}
public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
guard let data = try? JSONEncoder().encode(highlights) else {
return
}
if data.count > 1_048_576 {
do {
let data = try JSONEncoder().encode(highlights)
if data.count > 1_048_576 {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
#endif
}
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
}
public func loadReaderSettings() -> RDEPUBReaderSettings? {
guard let data = defaults.data(forKey: settingsKey) else {
return nil
}
return try? JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
do {
return try JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to decode reader settings: \(error)")
#endif
return nil
}
}
public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
guard let data = try? JSONEncoder().encode(settings) else {
return
do {
let data = try JSONEncoder().encode(settings)
defaults.set(data, forKey: settingsKey)
} catch {
#if DEBUG
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode reader settings: \(error)")
#endif
}
defaults.set(data, forKey: settingsKey)
}
}
@@ -154,11 +154,18 @@ public final class RDURLReaderController: UIViewController {
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
let safeInsets = view.safeAreaInsets
let edgeInsets = UIEdgeInsets(
top: max(epubConfiguration.reflowableContentInsets.top, safeInsets.top),
left: max(epubConfiguration.reflowableContentInsets.left, safeInsets.left),
bottom: max(epubConfiguration.reflowableContentInsets.bottom, safeInsets.bottom),
right: max(epubConfiguration.reflowableContentInsets.right, safeInsets.right)
)
let builder = RDPlainTextBookBuilder(
layoutConfig: RDEPUBTextLayoutConfig(
frameWidth: pageSize.width,
frameHeight: pageSize.height,
edgeInsets: epubConfiguration.reflowableContentInsets,
edgeInsets: edgeInsets,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: false,
@@ -3,19 +3,37 @@ 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() }
return storage[spineIndex]
guard let chapter = storage[spineIndex] else {
return nil
}
touchLocked(spineIndex)
return chapter
}
set {
lock.lock()
defer { lock.unlock() }
storage[spineIndex] = newValue
if let newValue {
storage[spineIndex] = newValue
touchLocked(spineIndex)
// Evict oldest entries if over limit
evictIfNeededLocked()
} else {
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
}
}
@@ -29,11 +47,29 @@ final class RDEPUBChapterDataCache {
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)
}
}
@@ -46,12 +46,29 @@ final class RDEPUBChapterLoader {
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) {
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
@@ -83,15 +100,15 @@ final class RDEPUBChapterLoader {
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [self] in
guard let context = self.context else {
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))
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
@@ -109,7 +126,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary,
context: context
context: context,
layoutSnapshot: layoutSnapshot
)
store.insertChapter(chapter)
@@ -137,7 +155,7 @@ final class RDEPUBChapterLoader {
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
return
}
store.markBuilding(false)
@@ -163,7 +181,8 @@ final class RDEPUBChapterLoader {
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
store: RDEPUBChapterRuntimeStore?,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
@@ -173,7 +192,7 @@ final class RDEPUBChapterLoader {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
@@ -202,12 +221,13 @@ final class RDEPUBChapterLoader {
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)
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@@ -219,7 +239,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary,
context: context
context: context,
layoutSnapshot: snapshot
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
@@ -288,16 +309,28 @@ final class RDEPUBChapterLoader {
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize = context.currentTextPageSize()
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
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 {
@@ -330,7 +363,8 @@ final class RDEPUBChapterLoader {
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig,
context: context
context: context,
layoutSnapshot: layoutSnapshot
)
}
@@ -542,7 +576,8 @@ final class RDEPUBChapterLoader {
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
@@ -559,7 +594,7 @@ final class RDEPUBChapterLoader {
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
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(
@@ -575,11 +610,22 @@ final class RDEPUBChapterLoader {
)
}
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let lineHeightMultiple: CGFloat
let lineHeightMultiple = context.configuration.lineHeightMultiple
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,
@@ -12,6 +12,10 @@ final class RDEPUBChapterRuntimeStore {
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] = []
@@ -39,6 +43,7 @@ final class RDEPUBChapterRuntimeStore {
init() {
imageCache.countLimit = 50
imageCache.totalCostLimit = 104_857_600 // 100 MB
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
@@ -172,7 +172,7 @@ struct RDEPUBChapterSummary: Codable {
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 16
static let currentSchemaVersion = 17
struct RangeData: Codable {
@@ -82,8 +82,6 @@ final class RDEPUBChapterWarmupOrchestrator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
if !chapterReady {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
@@ -105,7 +103,6 @@ final class RDEPUBChapterWarmupOrchestrator {
}
markPrepareResolved(pageNumber)
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
@@ -378,7 +375,6 @@ final class RDEPUBChapterWarmupOrchestrator {
switch result {
case .success:
self.markPrepareResolved(triggerPageNumber)
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
@@ -1,246 +0,0 @@
import Foundation
final class RDEPUBChapterWindowCoordinator {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
private let loader: RDEPUBChapterLoader
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
self.context = context
self.store = store
self.loader = loader
}
private var restoreChapterOffset: Int?
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
self.restoreChapterOffset = restoreChapterOffset
isSwitchingChapter = true
store.setNavigationTarget(spineIndex: targetSpineIndex)
store.clearPrefetchTargets()
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
}
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let chapter):
self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error):
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(
spineIndex: nextIndex,
totalSpineCount: totalSpineCount,
windowRadius: self.context.configuration.chapterWindowRadius
)
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
self.isSwitchingChapter = false
self.handle(error: error)
}
}
}
}
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
return
}
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
if spineIndex == chapter.spineIndex {
return chapter
}
return store.chapterData(for: spineIndex)
}
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
currentSnapshot = snapshot
isApplyingSnapshot = true
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
if let offset = restoreChapterOffset,
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
let targetPage = snapshot.anchorPageOffset + pageIndex
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
} else if snapshot.pageCount > 0 {
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
prefetchAdjacent(current: current)
}
private func prefetchAdjacent(current: Int) {
for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
store.addPrefetchTarget(spineIndex)
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
}
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
let totalSpineCount = context.publication?.spine.count ?? 0
guard next < totalSpineCount else { return }
flipToChapter(spineIndex: next, completion: completion)
}
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex, current > 0 else { return }
flipToChapter(spineIndex: current - 1, completion: completion)
}
func flipToChapter(
spineIndex: Int,
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setNavigationTarget(spineIndex: spineIndex)
store.clearPrefetchTargets()
isSwitchingChapter = true
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let evictable = store.evictableSpineIndices()
for idx in evictable {
store.evict(spineIndex: idx)
}
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
if let snap = currentSnapshot {
completion(.success(snap))
}
return
}
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
switch result {
case .success(let chapter):
self.buildSnapshotAroundCurrent(chapter: chapter)
if let snap = self.currentSnapshot {
completion(.success(snap))
}
case .failure(let error):
completion(.failure(error))
}
}
}
func refreshSnapshot() {
guard let current = store.currentSpineIndex,
let currentChapter = store.chapterData(for: current) else { return }
guard isReaderIdle() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshSnapshot()
}
return
}
let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) }
let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
currentSnapshot = newSnapshot
isApplyingSnapshot = true
onSnapshotChanged?(newSnapshot)
isApplyingSnapshot = false
}
}
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
for idx in store.evictableSpineIndices() {
store.evict(spineIndex: idx)
}
prefetchAdjacent(current: spineIndex)
}
private func handle(error: Error) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
}
}
private func snapshotContentChanged(
old: RDEPUBChapterWindowSnapshot?,
new: RDEPUBChapterWindowSnapshot
) -> Bool {
guard let old = old else { return true }
if old.chapters.count != new.chapters.count { return true }
let oldSpines = old.chapters.map { $0.spineIndex }
let newSpines = new.chapters.map { $0.spineIndex }
if oldSpines != newSpines { return true }
if old.pageCount != new.pageCount { return true }
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
if oldCh.pages.count != newCh.pages.count { return true }
}
if old.anchorChapterIndex != new.anchorChapterIndex
|| old.anchorPageOffset != new.anchorPageOffset { return true }
return false
}
private func isReaderIdle() -> Bool {
guard !store.isBuilding else { return false }
guard !isSwitchingChapter else { return false }
guard !isApplyingSnapshot else { return false }
return true
}
private var isSwitchingChapter: Bool = false
private var isApplyingSnapshot: Bool = false
}
@@ -1,58 +0,0 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
let chapters: [RDEPUBRuntimeChapter]
let flattenedPages: [RDEPUBTextPage]
let anchorChapterIndex: Int
let anchorPageOffset: Int
let windowStartSpineIndex: Int
static func from(
chapters: [RDEPUBRuntimeChapter],
anchorSpineIndex: Int
) -> RDEPUBChapterWindowSnapshot {
let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in sortedChapters.enumerated() {
for var page in ch.pages {
page.chapterIndex = chIdx
allPages.append(page)
}
}
let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
return RDEPUBChapterWindowSnapshot(
chapters: sortedChapters,
flattenedPages: allPages,
anchorChapterIndex: anchorIndex,
anchorPageOffset: pageOffset,
windowStartSpineIndex: windowStartSpineIndex
)
}
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
if flattenedPageIndex < offset + ch.pages.count {
return ch
}
offset += ch.pages.count
}
return nil
}
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
var pageCount: Int { flattenedPages.count }
}
@@ -63,6 +63,7 @@ final class RDEPUBBackgroundPriorityManager {
private(set) var policy: RDEPUBBackgroundPriorityPolicy
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
private let warmAnchorsLock = NSLock()
private(set) var currentGeneration: Int = 0
@@ -84,11 +85,13 @@ final class RDEPUBBackgroundPriorityManager {
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
@@ -103,10 +106,17 @@ final class RDEPUBBackgroundPriorityManager {
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
currentSpineIndex: currentSpineIndex,
warmAnchors: warmAnchorsSnapshot
)
return (spineIndex, band)
}
@@ -131,7 +141,8 @@ final class RDEPUBBackgroundPriorityManager {
private func classifySpineIndex(
spineIndex: Int,
currentSpineIndex: Int?
currentSpineIndex: Int?,
warmAnchors: [RDEPUBWarmJumpAnchor]
) -> RDEPUBPriorityBand {
if let current = currentSpineIndex {
@@ -152,11 +163,15 @@ final class RDEPUBBackgroundPriorityManager {
}
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
warmAnchors
warmAnchorsLock.lock()
defer { warmAnchorsLock.unlock() }
return warmAnchors
}
func reset() {
warmAnchorsLock.lock()
warmAnchors.removeAll()
warmAnchorsLock.unlock()
currentGeneration = 0
coldCursor = 0
}
@@ -26,7 +26,7 @@ final class RDEPUBMetadataParseWorker {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
unowned let context: RDEPUBReaderContext
weak var context: RDEPUBReaderContext?
let cancellationController: RDEPUBMetadataParseCancellationController
@@ -114,12 +114,16 @@ final class RDEPUBMetadataParseWorker {
}
func start(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
let cancellationController = self.cancellationController
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
defer { self.context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
let context = self.context
guard let context,
context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
@@ -382,10 +386,16 @@ final class RDEPUBMetadataParseWorker {
private func waitForReadingInteractionToSettle(
cancellationController: RDEPUBMetadataParseCancellationController? = nil
) {
while context.controller != nil,
let semaphore = DispatchSemaphore(value: 0)
let maxWaitIterations = 100 // ~8 seconds max wait
var iteration = 0
while context?.controller != nil,
cancellationController?.isCancelled != true,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
iteration < maxWaitIterations {
let elapsed = context?.secondsSinceLastUserNavigation() ?? 0
if elapsed >= backgroundInteractionCooldown { break }
semaphore.wait(timeout: .now() + 0.08)
iteration += 1
}
}
@@ -403,8 +413,7 @@ final class RDEPUBMetadataParseWorker {
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
let context = self.context
guard let self, let context = self.context else { return }
guard context.controller != nil,
context.paginationToken == self.token,
!cancellationController.isCancelled else {
@@ -1,76 +0,0 @@
import Foundation
enum RDEPUBNavigationState: Equatable {
case idle
case initialLoading
case restoringLocation
case preparingChapter(spineIndex: Int)
case presentingWindow
case reconcilingFullMap
case repaginating
}
final class RDEPUBNavigationStateMachine {
private let lock = NSLock()
private(set) var state: RDEPUBNavigationState = .idle
func transition(to newState: RDEPUBNavigationState) {
lock.lock()
let oldState = state
state = newState
lock.unlock()
#if DEBUG
validateTransition(from: oldState, to: newState)
#endif
}
#if DEBUG
private func validateTransition(from oldState: RDEPUBNavigationState, to newState: RDEPUBNavigationState) {
let isValid: Bool
switch (oldState, newState) {
case (.idle, .initialLoading),
(.idle, .preparingChapter),
(.idle, .restoringLocation),
(.idle, .idle):
isValid = true
case (.initialLoading, .preparingChapter),
(.initialLoading, .presentingWindow),
(.initialLoading, .idle),
(.initialLoading, .initialLoading):
isValid = true
case (.restoringLocation, .preparingChapter),
(.restoringLocation, .presentingWindow),
(.restoringLocation, .idle),
(.restoringLocation, .restoringLocation):
isValid = true
case (.preparingChapter, .presentingWindow),
(.preparingChapter, .preparingChapter),
(.preparingChapter, .idle):
isValid = true
case (.presentingWindow, .idle),
(.presentingWindow, .reconcilingFullMap),
(.presentingWindow, .preparingChapter),
(.presentingWindow, .presentingWindow),
(.presentingWindow, .repaginating):
isValid = true
case (.reconcilingFullMap, .presentingWindow),
(.reconcilingFullMap, .idle),
(.reconcilingFullMap, .reconcilingFullMap):
isValid = true
case (.repaginating, .presentingWindow),
(.repaginating, .idle),
(.repaginating, .repaginating):
isValid = true
default:
isValid = false
}
if !isValid {
#if DEBUG
assertionFailure("Unexpected navigation state transition: \(oldState)\(newState)")
#endif
}
}
#endif
}
@@ -1,22 +0,0 @@
import Foundation
enum RDEPUBPaginationStateSource: String {
case initialPartial
case asyncExtension
case pendingFullMap
case fullReplacement
case settingsPreview
case cacheRestore
case repagination
}
struct RDEPUBPaginationState {
var activePageMap: RDEPUBBookPageMap?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
var source: RDEPUBPaginationStateSource = .initialPartial
}
@@ -8,7 +8,6 @@ enum RDEPUBPendingPageMapUpdateKind {
struct RDEPUBPendingPageMapUpdate {
let pageMap: RDEPUBBookPageMap
let source: RDEPUBPaginationStateSource
let kind: RDEPUBPendingPageMapUpdateKind
}
@@ -22,10 +21,6 @@ final class RDEPUBPresentationRuntime {
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
let navigationStateMachine = RDEPUBNavigationStateMachine()
private(set) var paginationState = RDEPUBPaginationState()
init(
context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator,
@@ -43,19 +38,14 @@ final class RDEPUBPresentationRuntime {
restoreLocation: RDEPUBLocation?,
finishPagination: (RDEPUBLocation?) -> Void
) {
navigationStateMachine.transition(to: .presentingWindow)
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.pendingPageMapUpdates.removeAll()
paginationState.source = .initialPartial
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
navigationStateMachine.transition(to: .reconcilingFullMap)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
@@ -63,7 +53,6 @@ final class RDEPUBPresentationRuntime {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .pendingFullMap,
kind: .reconcileFullMap
)
)
@@ -99,7 +88,6 @@ final class RDEPUBPresentationRuntime {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .extendPartial(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
@@ -109,28 +97,19 @@ final class RDEPUBPresentationRuntime {
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .settingsPreview
}
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .appendForward
)
)
}
func clear() {
paginationState = RDEPUBPaginationState()
navigationStateMachine.transition(to: .idle)
}
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
@@ -162,7 +141,7 @@ final class RDEPUBPresentationRuntime {
let currentLocation = locationCoordinator.currentVisibleLocation()
context.textBook = nil
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
applyPageMapToLiveModel(newPageMap)
if let currentLocation {
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
@@ -201,7 +180,6 @@ final class RDEPUBPresentationRuntime {
updates.append(update)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
commitPendingPageMapUpdateIfNeeded()
}
@@ -219,7 +197,6 @@ final class RDEPUBPresentationRuntime {
) -> Bool {
switch update.kind {
case .reconcileFullMap:
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: update.pageMap,
candidateSegment: nil,
@@ -248,7 +225,7 @@ final class RDEPUBPresentationRuntime {
case .extendPartial(_, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
applyPageMapToLiveModel(update.pageMap)
if let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true
@@ -266,7 +243,7 @@ final class RDEPUBPresentationRuntime {
case .appendForward:
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
applyPageMapToLiveModel(update.pageMap)
readerView.reloadPageCountOnly()
return true
}
@@ -305,7 +282,7 @@ final class RDEPUBPresentationRuntime {
if context.bookPageMap != nil,
context.runtime?.prepareOnDemandChapter(
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: true
allowSynchronousLoad: false
) == false {
return false
}
@@ -317,16 +294,10 @@ final class RDEPUBPresentationRuntime {
return true
}
private func applyPageMapToLiveModel(
_ pageMap: RDEPUBBookPageMap,
source: RDEPUBPaginationStateSource
) {
navigationStateMachine.transition(to: .presentingWindow)
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
context.bookPageMap = pageMap
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
paginationState.activePageMap = pageMap
paginationState.source = source
}
private func removePendingPageMapUpdate(at index: Int) {
@@ -334,7 +305,6 @@ final class RDEPUBPresentationRuntime {
guard updates.indices.contains(index) else { return }
updates.remove(at: index)
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
@@ -346,7 +316,6 @@ final class RDEPUBPresentationRuntime {
)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
@@ -384,4 +353,4 @@ final class RDEPUBPresentationRuntime {
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
}
}
@@ -1,5 +1,14 @@
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()
@@ -73,11 +82,6 @@ final class RDEPUBReaderContext {
set { state.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { state.paginator }
set { state.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
@@ -159,43 +163,57 @@ final class RDEPUBReaderContext {
}
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
let safeInsets = controller?.view.safeAreaInsets ?? .zero
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 {
if Thread.isMainThread {
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
}
// 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
}
} else if let lastTextPaginationPageSize,
lastTextPaginationPageSize.width > 0,
lastTextPaginationPageSize.height > 0 {
}
let viewportSize = currentLayoutContext().viewportSize
if viewportSize.width > 0, viewportSize.height > 0 {
return viewportSize
}
if let lastTextPaginationPageSize,
lastTextPaginationPageSize.width > 0,
lastTextPaginationPageSize.height > 0 {
return lastTextPaginationPageSize
} else {
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
self?.currentTextPageSize() ?? .zero
}
if mainThreadSize.width > 0, mainThreadSize.height > 0 {
return mainThreadSize
}
}
return environment.fallbackViewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
environment.currentTextRenderStyle(configuration: configuration)
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextRenderStyle(configuration: configuration)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
dispatchPrecondition(condition: .onQueue(.main))
return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
@@ -282,10 +300,19 @@ final class RDEPUBReaderContext {
}
func currentRenderSignature() -> String {
dispatchPrecondition(condition: .onQueue(.main))
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return [
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)",
@@ -46,10 +46,11 @@ final class RDEPUBReaderEnvironment {
configuration: RDEPUBReaderConfiguration,
pageSize: CGSize
) -> RDEPUBTextLayoutConfig {
RDEPUBTextLayoutConfig(
let layoutContext = currentLayoutContext(configuration: configuration)
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
edgeInsets: layoutContext.safeReflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: false,
@@ -1,14 +1,14 @@
import Foundation
final class RDEPUBReaderLoadCoordinator {
private unowned let context: RDEPUBReaderContext
private weak var context: RDEPUBReaderContext?
init(context: RDEPUBReaderContext) {
self.context = context
}
func startInitialLoadIfNeeded() {
guard let controller = context.controller,
guard let context, let controller = context.controller,
let readerView = context.readerView,
!controller.didStartInitialLoad,
readerView.bounds.width > 0,
@@ -20,14 +20,14 @@ final class RDEPUBReaderLoadCoordinator {
}
func loadPublication() {
guard let controller = context.controller else { return }
guard let context, let controller = context.controller else { return }
context.showLoading()
let loadToken = UUID()
context.paginationToken = loadToken
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
guard let controller else { return }
let parser = self.context.makeParser()
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)
@@ -37,9 +37,10 @@ final class RDEPUBReaderLoadCoordinator {
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
DispatchQueue.main.async {
guard self.context.paginationToken == loadToken else { return }
self.context.runtime?.applyParsedPublication(
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,
@@ -49,9 +50,10 @@ final class RDEPUBReaderLoadCoordinator {
)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == loadToken else { return }
self.context.handle(error: error)
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)
}
}
}
@@ -65,14 +67,13 @@ final class RDEPUBReaderLoadCoordinator {
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
guard let controller = context.controller else { return }
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)
context.readingSession?.transition(to: .loading)
controller.title = parser.metadata.title.isEmpty
? controller.epubURL.deletingPathExtension().lastPathComponent
: parser.metadata.title
@@ -22,7 +22,6 @@ final class RDEPUBReaderLocationCoordinator {
}
guard let targetPageNumber = controller.pageNumber(for: location, rangeInfo: targetHighlightRangeInfo) else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
return false
}
@@ -44,7 +43,6 @@ final class RDEPUBReaderLocationCoordinator {
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else {
context.readingSession?.transition(to: .jumping)
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
@@ -23,7 +23,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
controller.isRepaginating = true
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .repaginating)
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
@@ -51,7 +50,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
let paginator = context.makePaginator()
context.paginator = paginator
paginator.calculate(
parser: parser,
hostingView: controller.ensurePaginationHostView(),
@@ -63,7 +61,6 @@ final class RDEPUBReaderPaginationCoordinator {
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
self.context.paginator = nil
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
@@ -106,15 +103,12 @@ final class RDEPUBReaderPaginationCoordinator {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
context.readingSession?.transition(to: .idle)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
}
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
@@ -171,10 +165,12 @@ final class RDEPUBReaderPaginationCoordinator {
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 context.controller != nil else { return }
guard let runtime else { return }
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
@@ -190,16 +186,16 @@ final class RDEPUBReaderPaginationCoordinator {
}
do {
guard context.controller != nil,
let runtime = context.runtime else { return }
let runtimeChapter = try self.loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: prioritizedCandidates,
runtime: runtime
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
let initialChapters = self.loadInitialInteractiveRuntimeChapters(
anchorChapter: runtimeChapter,
publication: publication,
runtime: runtime
runtime: runtime,
layoutSnapshot: layoutSnapshot
)
DispatchQueue.main.async {
@@ -238,14 +234,16 @@ final class RDEPUBReaderPaginationCoordinator {
private func loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: [Int],
runtime: RDEPUBReaderRuntime
runtime: RDEPUBReaderRuntime,
layoutSnapshot: RDEPUBLayoutSnapshot
) throws -> RDEPUBRuntimeChapter {
var lastError: Error?
for spineIndex in prioritizedSpineIndices {
do {
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
store: runtime.chapterRuntimeStore,
layoutSnapshot: layoutSnapshot
)
} catch {
lastError = error
@@ -257,7 +255,8 @@ final class RDEPUBReaderPaginationCoordinator {
private func loadInitialInteractiveRuntimeChapters(
anchorChapter: RDEPUBRuntimeChapter,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
runtime: RDEPUBReaderRuntime,
layoutSnapshot: RDEPUBLayoutSnapshot
) -> [RDEPUBRuntimeChapter] {
let minimumInteractivePageCount = 2
let maximumAdditionalChapters = 1
@@ -283,10 +282,14 @@ final class RDEPUBReaderPaginationCoordinator {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
store: runtime.chapterRuntimeStore,
layoutSnapshot: layoutSnapshot
)
selectedChapters.append(chapter)
} catch {
#if DEBUG
print("[RDEPUBReaderPaginationCoordinator] ⚠️ Failed to load chapter at spineIndex \(spineIndex): \(error)")
#endif
}
}
@@ -601,7 +601,6 @@ final class RDEPUBReaderRuntime {
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
presentationRuntime.clear()
}
func handleMemoryWarning() {
@@ -618,6 +617,7 @@ final class RDEPUBReaderRuntime {
activeWindowSpineIndices: activeWindowIndices,
protectedSpineIndices: protectedIndices
)
chapterRuntimeStore.handleMemoryWarning()
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
@@ -4,6 +4,24 @@ 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
}
@@ -20,18 +38,31 @@ final class RDEPUBReaderSearchCoordinator {
return
}
let matches = resolvedSearchMatches(for: normalizedKeyword)
controller.searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
notifySearchStateChanged()
let token = UUID()
currentSearchToken = token
if matches.isEmpty {
controller.refreshVisibleContentPreservingLocation()
} else {
_ = navigateToCurrentSearchMatch(animated: false)
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)
}
}
}
}
@@ -52,7 +83,6 @@ final class RDEPUBReaderSearchCoordinator {
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
@@ -60,6 +90,7 @@ final class RDEPUBReaderSearchCoordinator {
}
func clearSearch() {
currentSearchToken = UUID()
guard let controller else { return }
controller.searchState = nil
notifySearchStateChanged()
@@ -105,29 +136,43 @@ final class RDEPUBReaderSearchCoordinator {
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
}
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller else { return [] }
if let textBook = controller.textBook {
if let publication = controller.publication {
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
}
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
// 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)
}
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
return resolvedOnDemandSearchMatches(for: keyword)
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
return .onDemand(publication)
}
if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
return .html(parser, publication)
}
return []
return nil
}
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller,
let publication = controller.publication else {
return []
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 .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 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 []
@@ -140,52 +185,58 @@ final class RDEPUBReaderSearchCoordinator {
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: controller.runtime.chapterRuntimeStore
) else {
continue
}
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 { continue }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
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 chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
layoutSnapshot: layoutSnapshot
) else {
return []
}
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
progression: progression,
previewText: previewText(in: source, matchRange: foundRange),
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
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
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return localMatches
} // end autoreleasepool
matches.append(contentsOf: chapterMatches)
}
return matches
@@ -236,8 +287,9 @@ final class RDEPUBReaderSearchCoordinator {
private func notifySearchStateChanged() {
guard let controller else { return }
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
let state = controller.searchState
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
}
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
@@ -346,4 +398,4 @@ final class RDEPUBReaderSearchCoordinator {
return nil
}
}
}
@@ -1,5 +1,9 @@
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?
@@ -20,8 +24,6 @@ final class RDEPUBReaderState {
var paginationToken = UUID()
var paginator: RDEPUBPaginator?
var searchState: RDEPUBSearchState?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
@@ -169,11 +169,19 @@ extension RDEPUBReaderConfiguration {
extension RDEPUBReaderConfiguration {
func makePreferences() -> RDEPUBPreferences {
RDEPUBPreferences(
func makePreferences(safeAreaInsets: UIEdgeInsets = .zero) -> RDEPUBPreferences {
// Use the larger of reflowableContentInsets and safeAreaInsets for each edge
// to prevent content from being hidden under Dynamic Island / home indicator.
let safeInsets = UIEdgeInsets(
top: max(reflowableContentInsets.top, safeAreaInsets.top),
left: max(reflowableContentInsets.left, safeAreaInsets.left),
bottom: max(reflowableContentInsets.bottom, safeAreaInsets.bottom),
right: max(reflowableContentInsets.right, safeAreaInsets.right)
)
return RDEPUBPreferences(
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple,
reflowableContentInsets: reflowableContentInsets,
reflowableContentInsets: safeInsets,
fixedContentInset: fixedContentInset,
numberOfColumns: numberOfColumns,
columnGap: columnGap,
@@ -6,7 +6,20 @@ import DTCoreText
enum RDEPUBDarkImageAdjuster {
private static let imageCache = NSCache<NSString, UIImage>()
private static let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 52_428_800 // 50 MB
return cache
}()
private static func imageCost(of image: UIImage) -> Int {
let scale = image.scale
let width = Int(image.size.width * scale)
let height = Int(image.size.height * scale)
// 4 bytes per pixel (RGBA)
return width * height * 4
}
#if canImport(DTCoreText)
@@ -84,7 +97,7 @@ enum RDEPUBDarkImageAdjuster {
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
imageCache.setObject(adjusted, forKey: cacheKey)
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
return adjusted
}
@@ -46,6 +46,10 @@ extension RDEPUBTextContentViewDelegate {
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
private static let pageNumberTrailingPadding: CGFloat = 4
private static let pageNumberFooterPadding: CGFloat = 8
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
@@ -56,6 +60,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
private var menuSelection: RDEPUBSelection?
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
// Stored as Any? to avoid @available on stored property restriction.
private var _editMenuInteraction: Any?
private let selectionLoupeView = RDEPUBSelectionLoupeView()
private var currentHighlights: [RDEPUBHighlight] = []
@@ -241,6 +249,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
selectionLoupeView.isHidden = true
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
if #available(iOS 16.0, *) {
let interaction = UIEditMenuInteraction(delegate: self)
addInteraction(interaction)
self._editMenuInteraction = interaction
}
selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return }
self.currentSelection = selection
@@ -322,10 +337,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
overlayView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
let contentRect = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
)
let footerOriginY = bounds.maxY - contentInsets.bottom
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
y: footerOriginY + footerVerticalInset,
width: labelSize.width,
height: labelSize.height
)
@@ -352,7 +372,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
@@ -434,7 +457,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
contentInsets = Self.safeContentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
)
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
@@ -471,7 +497,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
hideSelectionMenu()
}
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
@@ -562,25 +588,47 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
guard content.length > 0 else { return content }
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else { return content }
guard !firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
if shouldNormalizeLeadingParagraphSpacing(for: page) {
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
updateParagraphStyle(in: content, range: leadingRange) { style in
style.paragraphSpacingBefore = 0
}
}
guard shouldNormalizeContinuationParagraph(for: page),
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
style.firstLineHeadIndent = style.headIndent
style.paragraphSpacingBefore = 0
}
return content
}
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
page.pageStartOffset == 0
}
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
let text = content.string as NSString
var index = 0
while index < text.length {
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
return text.paragraphRange(for: NSRange(location: index, length: 0))
}
index += 1
}
return nil
}
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
@@ -589,6 +637,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
return !CharacterSet.newlines.contains(previousScalar)
}
private func updateParagraphStyle(
in content: NSMutableAttributedString,
range: NSRange,
transform: (NSMutableParagraphStyle) -> Void
) {
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
transform(mutableStyle)
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
}
}
private func firstParagraphUsesNonLeadingAlignment(
in content: NSAttributedString,
range: NSRange
@@ -743,19 +805,29 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
!targetRect.isEmpty else {
return
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
let anchor = CGPoint(x: targetRect.midX, y: targetRect.midY)
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
interaction.presentEditMenu(with: config)
} else {
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
}
}
private func hideSelectionMenu() {
UIMenuController.shared.setMenuVisible(false, animated: true)
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
interaction.dismissMenu()
} else {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
}
private func updateSelectionLoupe(for point: CGPoint) {
@@ -954,3 +1026,51 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
)
}
}
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
@available(iOS 16.0, *)
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration
) -> UIMenu {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
])
}
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
targetRectFor configuration: UIEditMenuConfiguration
) -> CGRect {
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty {
return targetRect
}
return bounds
}
}
// MARK: - Safe Content Insets
extension RDEPUBTextContentView {
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
private static func safeContentInsets(
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)
)
}
}
@@ -148,7 +148,6 @@ final class RDEPUBTextSelectionController: NSObject {
selectionEndIndex = NSNotFound
setInteractionState(.idle)
renderView?.selectionRects = []
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}