feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -1,22 +1,30 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let threadName = resolvedThreadName()
let queueLabel = resolvedQueueLabel()
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
}
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
let startedAt = CFAbsoluteTimeGetCurrent()
log(scope, "START \(message)")
do {
let result = try work()
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
return result
} catch {
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
throw error
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
}
private static func resolvedThreadName() -> String {
if let name = Thread.current.name, !name.isEmpty {
return name
}
if Thread.isMainThread {
return "main"
}
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
}
@@ -1,29 +1,26 @@
import Foundation
/// BookPageMap
/// NSAttributedString 100
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
///
let pageCount: Int
/// 0
let absolutePageStart: Int
/// fragment ID
let fragmentOffsets: [String: Int]
}
///
/// 100 /1000 100KB
///
/// spineIndex
///
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
/// spineIndex
private let indexBySpine: [Int: Int] // spineIndex -> entries
///
private let indexBySpine: [Int: Int]
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
@@ -38,9 +35,6 @@ struct RDEPUBBookPageMap {
static let empty = RDEPUBBookPageMap(entries: [])
// MARK: -
/// spineIndex +
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
@@ -48,10 +42,9 @@ struct RDEPUBBookPageMap {
return entry.absolutePageStart + localPageIndex
}
/// spineIndex
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
// entries absolutePageStart
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
@@ -65,7 +58,6 @@ struct RDEPUBBookPageMap {
return entries[lo - 1].spineIndex
}
///
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
@@ -75,29 +67,23 @@ struct RDEPUBBookPageMap {
return local
}
/// spineIndex
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
/// spineIndex entries
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
/// spineIndex
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
///
var totalChapters: Int { entries.count }
// MARK: -
/// Builder pageCount BookPageMap
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]) {
@@ -105,7 +91,7 @@ struct RDEPUBBookPageMap {
}
func build() -> RDEPUBBookPageMap {
// spineIndex
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
@@ -1,8 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private let lock = NSLock()
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBChapterLoader {
private unowned let context: RDEPUBReaderContext
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
init(context: RDEPUBReaderContext) {
@@ -12,24 +14,22 @@ final class RDEPUBChapterLoader {
summaryDiskCache = cache
}
// MARK: -
enum LoadPriority {
case navigation //
case preview //
case prefetch // ±1 +
case navigation
case preview
case prefetch
}
// MARK: -
/// chapterLoadQueue 线
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
// 1. 线 completion 线
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
DispatchQueue.main.async {
@@ -38,14 +38,12 @@ final class RDEPUBChapterLoader {
return
}
// 2-3. + pageCountCache
// contentHashForSpineIndex SHA256 + I/O 线
store.markBuilding(true)
store.chapterLoadQueue.async {
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
// pageCountCache
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
@@ -63,6 +61,7 @@ final class RDEPUBChapterLoader {
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
do {
let chapter = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
@@ -75,7 +74,6 @@ final class RDEPUBChapterLoader {
}
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
// 5.
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
@@ -86,13 +84,12 @@ final class RDEPUBChapterLoader {
)
store.insertPageCount(pc, for: cacheKey)
// 6.
switch priority {
case .navigation:
// §14.4
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
//
store.markBuilding(false)
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
return
@@ -109,8 +106,7 @@ final class RDEPUBChapterLoader {
}
case .prefetch:
//
//
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
@@ -118,6 +114,7 @@ final class RDEPUBChapterLoader {
}
}
} catch {
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
store.markBuilding(false)
DispatchQueue.main.async {
@@ -127,14 +124,6 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - legacy 使
///
/// - chapterLoadQueue WXRead
/// - RDEPUBTextBook
/// - 线 chapterLoadQueue
/// - store.assertNotOnChapterLoadQueue()
/// - 使
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
@@ -193,8 +182,6 @@ final class RDEPUBChapterLoader {
return try result!.get()
}
// MARK: -
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
@@ -210,7 +197,7 @@ final class RDEPUBChapterLoader {
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
if let pageRanges = availablePageRanges {
// ---- pageCountCache chapterSummaryDiskCache ----
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
@@ -224,7 +211,6 @@ final class RDEPUBChapterLoader {
)
}
// ---- + ----
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
@@ -245,8 +231,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - pageRanges
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
@@ -259,14 +243,14 @@ final class RDEPUBChapterLoader {
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title ?? ""
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
// 1. HTML NSAttributedString
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: try requireHTMLString(parser, href: href),
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
@@ -281,14 +265,24 @@ final class RDEPUBChapterLoader {
in: typesetString, style: style, layoutConfig: layoutConfig
)
// 2. metadata
// - diskSummary metadata
// - diskSummary pageCountCache attributedString
let metadataSource = diskSummary?.pageMetadataList
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 {
RDEPUBBackgroundTrace.log(
"ChapterLoader",
"缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)"
)
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
// 3. pageRanges pages CoreText
let pages = buildPagesFromRanges(
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
@@ -296,35 +290,39 @@ final class RDEPUBChapterLoader {
metadataSource: metadataSource
)
// 4. layouter
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
// 5. chapterOffsetMap
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset }
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: rendered.fragmentOffsets,
rawHTML: rawHTML,
chapterText: typesetString.string
),
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil, // source
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: pageRanges,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
// MARK: - pageRanges RDEPUBTextPage
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
@@ -338,10 +336,10 @@ final class RDEPUBChapterLoader {
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
// metadata
metadata = metaList[pageIndex].toPageMetadata()
} else {
// metadata attributedString
metadata = inferPageMetadata(
from: typesetString,
range: range,
@@ -366,7 +364,21 @@ final class RDEPUBChapterLoader {
}
}
// MARK: - attributedString 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,
@@ -431,8 +443,6 @@ final class RDEPUBChapterLoader {
)
}
// MARK: - RDEPUBRuntimeChapter
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
@@ -448,17 +458,25 @@ final class RDEPUBChapterLoader {
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap ?? makeCFIMap(
href: chapter.href,
spineIndex: spineIndex,
fragmentOffsets: chapter.fragmentOffsets,
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
chapterText: chapter.attributedContent.string
),
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
// P2
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
@@ -479,13 +497,10 @@ final class RDEPUBChapterLoader {
)
}
// MARK: -
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
// renderSignature §8.2
let lineHeightMultiple = context.configuration.lineHeightMultiple
let renderSignature = [
@@ -521,11 +536,57 @@ final class RDEPUBChapterLoader {
}
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).sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
@@ -1,17 +1,15 @@
import Foundation
///
/// RDEPUBLocation progression
public struct RDEPUBChapterLocation: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0-based
public var chapterOffset: Int
/// HTML fragment ID #section1
public var fragmentID: String?
/// progressionfragmentID nil
public var progressionInChapter: Double?
/// schema 1=, 2=
public var schemaVersion: Int
public init(
@@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable {
self.schemaVersion = schemaVersion
}
/// schemaVersion == 1 chapterOffset progression
var isFallbackEstimate: Bool { schemaVersion == 1 }
}
@@ -1,16 +1,35 @@
import Foundation
struct RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
/// fragmentID ->
let cfiMap: RDEPUBCFIMap?
let chapterText: String?
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
/// -> 0
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] {
@@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap {
}
return nil
}
}
}
@@ -2,50 +2,36 @@ import UIKit
final class RDEPUBChapterRuntimeStore {
// MARK: -
/// WXRead chapterDataCache
private let chapterDataCache = RDEPUBChapterDataCache()
/// WXRead pageCountCache
private let pageCountCache = RDEPUBPageCountCache()
/// NSCache WXRead imageCache
let imageCache = NSCache<NSString, UIImage>()
/// WXRead com.weread.chapterload
/// QoS .userInitiated/
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// MARK: -
/// spineIndex
private(set) var currentSpineIndex: Int?
/// spineIndex
private(set) var windowSpineIndices: [Int] = []
// MARK: - vs
/// ///
///
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
/// ±1
///
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
///
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
// MARK: -
init() {
imageCache.countLimit = 50
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
@@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
// MARK: - 线 cache wrapper lock
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
@@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore {
return pageCountCache[key]
}
// MARK: -
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
}
@@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore {
pageCountCache[key] = pc
}
// MARK: -
///
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
@@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore {
windowSpineIndices = Array(lowerBound...upperBound)
}
/// spineIndex
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
// MARK: -
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
@@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore {
if let ch = currentChapter {
chapterDataCache[current] = ch
}
// WXRead pageCountCache
pageCountCache.removeAll()
}
// MARK: -
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
// MARK: - §14.4
///
///
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
///
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
@@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore {
return target
}
// MARK: -
/// ±1
///
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)
@@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore {
buildingLock.unlock()
}
// MARK: - P1: §8.5
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
@@ -1,8 +1,11 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
@@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: -
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 { }
}
// MARK: - loadChapter
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
let data: Data
@@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache {
} 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)")
@@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache {
}
}
// MARK: - BookPageMap
/// spineIndex summary
/// 线
/// contentHash
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
@@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache {
return (summaries, mapBuilder)
}
///
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
@@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache {
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)
@@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache {
return (count, totalBytes)
}
// MARK: - key ->
/// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
@@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache {
}
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 = 6
static let currentSchemaVersion = 9
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 {
@@ -1,14 +1,15 @@
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) {
@@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator {
self.loader = loader
}
///
private var restoreChapterOffset: Int?
// MARK: -
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(
@@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator {
)
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 }
@@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator {
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(
@@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator {
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
//
self.isSwitchingChapter = false
self.handle(error: error)
}
@@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
#if DEBUG
@@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator {
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
// chapterOffset
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 {
//
// reloadData() switchReaderDisplayType currentPage -1 0
// 0 anchorPageOffset transition
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
//
prefetchAdjacent(current: current)
}
// MARK: -
private func prefetchAdjacent(current: Int) {
for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
@@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
///
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
@@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator {
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,
@@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator {
store.evict(spineIndex: idx)
}
//
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
@@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator {
return
}
//
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
@@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: -
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()
@@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator {
}
}
// MARK: - P1:
///
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
@@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator {
prefetchAdjacent(current: spineIndex)
}
// MARK: -
private func handle(error: Error) {
//
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
#endif
// loading
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
//
if self.currentSnapshot == nil {
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
@@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator {
return true
}
///
private var isSwitchingChapter: Bool = false
///
private var isApplyingSnapshot: Bool = false
}
@@ -1,26 +1,17 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
///
let chapters: [RDEPUBRuntimeChapter]
/// RDReaderView
/// RDEPUBTextPage
/// flattenedPages 0
let flattenedPages: [RDEPUBTextPage]
/// chapters
let anchorChapterIndex: Int
/// flattenedPages 0
let anchorPageOffset: Int
/// spineIndex
let windowStartSpineIndex: Int
// MARK: -
///
static func from(
chapters: [RDEPUBRuntimeChapter],
anchorSpineIndex: Int
@@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot {
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 {
@@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot {
)
}
// MARK: -
/// flattenedPages ->
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
@@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot {
return nil
}
/// flattenedPages -> spineIndex
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
///
var pageCount: Int { flattenedPages.count }
}
@@ -1,7 +1,9 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
@@ -1,13 +1,18 @@
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) {
@@ -1,26 +1,23 @@
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(
@@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter {
self.chapterOffsetMap = chapterOffsetMap
}
/// sourceAttributedString
func releaseSourceText() {
sourceAttributedString = nil
}
@@ -1,9 +1,14 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}
@@ -1,8 +1,11 @@
import CryptoKit
extension String {
var sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}
@@ -1,65 +1,56 @@
import Foundation
///
struct RDEPUBBackgroundCoverageSegment {
/// spineIndex
let lowerSpineIndex: Int
/// spineIndex
let upperSpineIndex: Int
///
let pageMap: RDEPUBBookPageMap
/// spineIndex
let resolvedSpineIndices: Set<Int>
///
let generatedAt: CFAbsoluteTime
///
let renderSignature: String
///
let estimatedMemoryBytes: Int
/// spineIndex
func contains(spineIndex: Int) -> Bool {
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
}
/// spineIndex
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 // 8MB
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
/// 访 LRU
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
@@ -67,12 +58,10 @@ final class RDEPUBBackgroundCoverageStore {
self.policy = policy
}
///
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
//
evictIfNeeded(forNewSegment: segment)
//
var merged = false
for (index, existing) in segments.enumerated() {
if canMerge(existing, segment) {
@@ -94,7 +83,6 @@ final class RDEPUBBackgroundCoverageStore {
currentMemoryBytes += segment.estimatedMemoryBytes
}
// 访
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
RDEPUBBackgroundTrace.log(
@@ -103,7 +91,6 @@ final class RDEPUBBackgroundCoverageStore {
)
}
/// spineIndex
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
if let segment {
@@ -112,7 +99,6 @@ final class RDEPUBBackgroundCoverageStore {
return segment
}
/// spineIndex
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { segment in
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
@@ -123,19 +109,16 @@ final class RDEPUBBackgroundCoverageStore {
return segment
}
///
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
segments
}
///
func clearAll() {
segments.removeAll()
currentMemoryBytes = 0
lastAccessTime.removeAll()
}
///
func clearColdSegments(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
@@ -151,7 +134,6 @@ final class RDEPUBBackgroundCoverageStore {
}
}
///
func handleMemoryWarning(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
@@ -161,15 +143,13 @@ final class RDEPUBBackgroundCoverageStore {
"memory warning: clearing cold segments, current=\(currentMemoryBytes)B"
)
//
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
@@ -194,24 +174,20 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
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() {
@@ -232,33 +208,27 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
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
}
// resolvedSpineIndices
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)
@@ -274,7 +244,6 @@ final class RDEPUBBackgroundCoverageStore {
)
}
///
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
@@ -300,7 +269,6 @@ final class RDEPUBBackgroundCoverageStore {
return builder.build()
}
///
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
256 + pageMap.entries.count * 96 + resolvedCount * 16
}
@@ -1,19 +1,15 @@
import Foundation
///
///
///
struct RDEPUBBackgroundPriorityPolicy {
///
let hotRadius: Int
///
let warmRadius: Int
///
let maxWarmJumpAnchors: Int
///
let coldLaneShare: Double
///
static let `default` = RDEPUBBackgroundPriorityPolicy(
hotRadius: 24,
warmRadius: 96,
@@ -21,7 +17,6 @@ struct RDEPUBBackgroundPriorityPolicy {
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)
@@ -34,7 +29,6 @@ struct RDEPUBBackgroundPriorityPolicy {
}
}
///
enum RDEPUBPriorityBand: Int, Comparable {
case hot = 0
case warmPrimary = 1
@@ -46,39 +40,32 @@ enum RDEPUBPriorityBand: Int, Comparable {
}
}
///
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] = []
/// generation
private(set) var currentGeneration: Int = 0
///
private var coldCursor: Int = 0
init(context: RDEPUBReaderContext) {
@@ -86,12 +73,10 @@ final class RDEPUBBackgroundPriorityManager {
self.policy = .default
}
///
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
policy = newPolicy
}
///
func addWarmAnchor(spineIndex: Int) {
let anchor = RDEPUBWarmJumpAnchor(
spineIndex: spineIndex,
@@ -101,13 +86,12 @@ final class RDEPUBBackgroundPriorityManager {
warmAnchors.insert(anchor, at: 0)
// N
if warmAnchors.count > policy.maxWarmJumpAnchors {
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
}
currentGeneration += 1
coldCursor = 0 //
coldCursor = 0
RDEPUBBackgroundTrace.log(
"PriorityManager",
@@ -115,7 +99,6 @@ final class RDEPUBBackgroundPriorityManager {
)
}
/// spineIndex
func makeMetadataPriorityOrder(
allBuildableIndices: [Int],
currentSpineIndex: Int?,
@@ -124,7 +107,6 @@ final class RDEPUBBackgroundPriorityManager {
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
guard !uncachedIndices.isEmpty else { return [] }
// spineIndex
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
let band = classifySpineIndex(
spineIndex: spineIndex,
@@ -133,33 +115,29 @@ final class RDEPUBBackgroundPriorityManager {
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
}
// spineIndex
return lhs.spineIndex < rhs.spineIndex
}
return sorted.map { $0.spineIndex }
}
/// spineIndex
private func classifySpineIndex(
spineIndex: Int,
currentSpineIndex: Int?
) -> RDEPUBPriorityBand {
//
if let current = currentSpineIndex {
let distance = abs(spineIndex - current)
if distance <= policy.hotRadius {
@@ -167,7 +145,6 @@ final class RDEPUBBackgroundPriorityManager {
}
}
//
for (index, anchor) in warmAnchors.enumerated() {
let distance = abs(spineIndex - anchor.spineIndex)
if distance <= policy.warmRadius {
@@ -175,16 +152,13 @@ final class RDEPUBBackgroundPriorityManager {
}
}
//
return .cold
}
///
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
warmAnchors
}
///
func reset() {
warmAnchors.removeAll()
currentGeneration = 0
@@ -1,56 +1,47 @@
import Foundation
///
///
///
/// -
/// -
struct RDEPUBJumpSession {
/// spineIndex
let anchorSpineIndex: Int
///
let createdAt: CFAbsoluteTime
/// spineIndex
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
}
}
/// JumpSession
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,
@@ -71,26 +62,20 @@ public struct RDEPUBJumpSessionPolicy: Equatable {
}
}
/// JumpSession
final class RDEPUBJumpSessionManager {
private unowned let context: RDEPUBReaderContext
/// JumpSession
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
@@ -100,7 +85,6 @@ final class RDEPUBJumpSessionManager {
self.context = context
}
/// JumpSession
@discardableResult
func createSession(
anchorSpineIndex: Int,
@@ -110,7 +94,6 @@ final class RDEPUBJumpSessionManager {
let policy = context.configuration.jumpSessionPolicy
let now = CFAbsoluteTimeGetCurrent()
//
var protectedIndices: Set<Int> = [anchorSpineIndex]
for offset in 1...policy.protectedNeighborRadius {
let lower = anchorSpineIndex - offset
@@ -146,7 +129,6 @@ final class RDEPUBJumpSessionManager {
return session
}
///
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
guard activeSession != nil else { return }
@@ -161,29 +143,24 @@ final class RDEPUBJumpSessionManager {
}
}
///
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
guard let session = activeSession else {
return true // Session
return true
}
//
let protectedIndices = session.protectedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
Double(protectedIndices.count)
// 80%
return coverageRatio >= 0.8
}
/// Session
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
guard let session = activeSession else { return nil }
let now = CFAbsoluteTimeGetCurrent()
let policy = context.configuration.jumpSessionPolicy
// 1.
if now >= session.expiresAt {
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
RDEPUBBackgroundTrace.log(
@@ -194,7 +171,6 @@ final class RDEPUBJumpSessionManager {
}
}
// 2.
if !session.protectedSpineIndices.contains(currentSpineIndex) {
if consecutivePageCount >= policy.exitPageThreshold {
RDEPUBBackgroundTrace.log(
@@ -204,14 +180,13 @@ final class RDEPUBJumpSessionManager {
return .navigatedAway
}
} else {
//
consecutivePageCount = 0
}
return nil
}
/// Session
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
guard let session = activeSession else { return }
RDEPUBBackgroundTrace.log(
@@ -223,7 +198,6 @@ final class RDEPUBJumpSessionManager {
lastPageDirection = nil
}
/// Session
func clearSession() {
activeSession = nil
consecutivePageCount = 0
@@ -1,33 +1,31 @@
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)
@@ -35,7 +33,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
//
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
@@ -46,11 +43,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}) ?? 0
// JumpSession
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) /
@@ -66,7 +62,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let currentSpineIndex {
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
@@ -85,7 +80,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let candidateSegment {
let currentRenderSignature = context.currentRenderSignature()
if candidateSegment.renderSignature != currentRenderSignature {
@@ -97,7 +91,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
if let candidateSegment {
return evaluateSegmentTakeover(
candidateSegment: candidateSegment,
@@ -119,7 +112,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
///
private func evaluateSegmentTakeover(
candidateSegment: RDEPUBBackgroundCoverageSegment,
currentWindow: RDEPUBBookPageMap,
@@ -129,14 +121,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
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) ||
@@ -146,21 +136,20 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
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)
}
}
@@ -168,7 +157,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
///
private func evaluateFullPageMapTakeover(
candidatePageMap: RDEPUBBookPageMap,
currentWindow: RDEPUBBookPageMap,
@@ -177,14 +165,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
//
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) ||
@@ -194,7 +180,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
//
let isComplete = candidateIndices.count >= currentWindow.entries.count
if isComplete {
return .fullReplace(candidatePageMap)
@@ -203,7 +188,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .keepCurrentWindow
}
/// spineIndex
func protectedSpineIndices(
currentSpineIndex: Int?,
jumpSession: RDEPUBJumpSession?
@@ -212,7 +196,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
if let currentSpineIndex {
indices.insert(currentSpineIndex)
//
if currentSpineIndex > 0 {
indices.insert(currentSpineIndex - 1)
}
@@ -1,13 +1,7 @@
import UIKit
/// EPUB
///
///
/// - highlightannotation
/// - bookmark
/// -
/// -
final class RDEPUBReaderAnnotationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -18,19 +12,16 @@ final class RDEPUBReaderAnnotationCoordinator {
context.controller
}
/// ID
func bookmark(withID id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
return controller.activeBookmarks.first { $0.id == 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))
@@ -39,9 +30,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
/// `.selected` context chrome delegate
/// `.idle` chrome delegate
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
context.selectionState = state
@@ -52,9 +40,6 @@ final class RDEPUBReaderAnnotationCoordinator {
case .selecting:
break
case .selected(let selection):
if controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
@@ -62,7 +47,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
@@ -72,7 +56,6 @@ final class RDEPUBReaderAnnotationCoordinator {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
}
/// /线/
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
@@ -115,7 +98,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return newHighlight
}
/// upsert ID
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -132,7 +114,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return scopedHighlight
}
/// ID
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -144,7 +125,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return removed
}
///
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let controller else { return nil }
@@ -156,7 +136,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.activeHighlights[index]
}
///
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let highlight = highlight(withID: id) else {
@@ -165,7 +144,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return navigate(to: highlight, animated: animated)
}
///
func removeAllHighlights() {
guard let controller else { return }
guard !controller.activeHighlights.isEmpty else { return }
@@ -173,7 +151,6 @@ final class RDEPUBReaderAnnotationCoordinator {
persistHighlightsAndRefreshContent()
}
/// spine
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
@@ -190,7 +167,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor
rangeAnchor: selection.location.rangeAnchor,
cfi: selection.location.cfi,
lastCFI: selection.location.lastCFI,
rangeCFI: selection.location.rangeCFI
)
return RDEPUBSelection(
bookIdentifier: controller.currentBookIdentifier,
@@ -201,7 +181,6 @@ final class RDEPUBReaderAnnotationCoordinator {
)
}
///
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
@@ -231,7 +210,6 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(navigationController, animated: true)
}
/// /线/
func presentAnnotationCreation() {
guard let controller else { return }
guard controller.configuration.allowsHighlights,
@@ -241,7 +219,6 @@ final class RDEPUBReaderAnnotationCoordinator {
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)
@@ -263,7 +240,6 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(alert, animated: true)
}
///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
@@ -277,7 +253,6 @@ final class RDEPUBReaderAnnotationCoordinator {
}
}
///
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -299,7 +274,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return newBookmark
}
///
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -315,7 +289,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return addBookmark(note: note)
}
/// ID
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
@@ -327,7 +300,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return removed
}
///
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
@@ -337,7 +309,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.restoreReadingLocation(bookmark.location, animated: animated)
}
///
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
@@ -374,7 +345,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor
rangeAnchor: highlight.location.rangeAnchor,
cfi: highlight.location.cfi,
lastCFI: highlight.location.lastCFI,
rangeCFI: highlight.location.rangeCFI
)
return RDEPUBHighlight(
id: highlight.id,
@@ -407,7 +381,21 @@ final class RDEPUBReaderAnnotationCoordinator {
}
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
controller.updateReaderChrome()
controller.refreshVisibleContentPreservingLocation()
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) {
@@ -511,6 +499,11 @@ final class RDEPUBReaderAnnotationCoordinator {
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
@@ -545,7 +538,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
@@ -559,7 +555,10 @@ final class RDEPUBReaderAnnotationCoordinator {
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
@@ -1,11 +1,5 @@
import UIKit
/// EPUB UI
///
///
/// - readerViewloadingIndicatorerrorLabel
/// -
/// -
final class RDEPUBReaderAssemblyCoordinator {
private unowned let context: RDEPUBReaderContext
@@ -13,7 +7,6 @@ final class RDEPUBReaderAssemblyCoordinator {
self.context = context
}
/// readerViewloadingIndicatorerrorLabel
func assembleInterface() {
guard let controller = context.controller,
let readerView = context.readerView else { return }
@@ -28,7 +21,6 @@ final class RDEPUBReaderAssemblyCoordinator {
#endif
}
///
func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime,
let controller = context.controller,
@@ -1,14 +1,7 @@
import UIKit
/// EPUB Chrome /
///
///
/// -
/// -
/// -
/// -
/// -
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -19,14 +12,11 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
context.controller
}
// MARK: - UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
//
context.runtime?.settingsPanelDidDisappear()
}
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
@@ -41,7 +31,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return toolView
}
///
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
let toolView = RDEPUBReaderBottomToolView()
toolView.onShowTableOfContents = { [weak self] in
@@ -62,7 +51,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return toolView
}
///
func updateReaderChrome() {
guard let controller else { return }
let uiState = makeUIState()
@@ -70,7 +58,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
updateSearchBar()
}
/// UI
func makeUIState() -> RDEPUBReaderUIState {
guard let controller else { return .empty }
return RDEPUBReaderUIState(
@@ -85,7 +72,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
)
}
/// UI
func applyUIState(_ state: RDEPUBReaderUIState) {
guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme)
@@ -107,18 +93,15 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
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 }
// Runtime
context.runtime?.settingsPanelWillAppear()
let settingsController = RDEPUBReaderSettingsViewController(
@@ -147,7 +130,7 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller?.updateConfiguration { $0.theme = theme }
}
settingsController.onDismiss = { [weak self] in
// Runtime
self?.context.runtime?.settingsPanelDidDisappear()
}
@@ -157,7 +140,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller.present(navigationController, animated: true)
}
///
func presentTableOfContents() {
guard let controller else { return }
guard controller.configuration.showsTableOfContents else { return }
@@ -184,7 +166,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
controller.present(navigationController, animated: true)
}
/// /
func toggleSearchBar() {
guard let controller else { return }
if controller.isSearchBarVisible {
@@ -194,7 +175,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
}
///
func updateSearchBar() {
guard let controller else { return }
controller.searchBarView.apply(theme: controller.configuration.theme)
@@ -207,7 +187,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
}
/// pop dismiss
func handleBackAction() {
guard let controller else { return }
close(controller)
@@ -1,65 +1,51 @@
import UIKit
/// coordinator context 访便
///
/// context
/// - parserpublicationtextBookpages
/// - UI configurationbrightness
/// - persistence
/// - 便renderStylelayoutConfig
/// - controller UIKit
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
// MARK: -
/// UIKit
weak var controller: RDEPUBReaderController?
///
weak var readerView: RDReaderView?
///
var dependencies: RDEPUBReaderDependencies = .live
/// 便访
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
// MARK: -
/// EPUB
var parser: RDEPUBParser?
///
var publication: RDEPUBPublication?
///
var readingSession: RDEPUBReadingSession?
///
/// nil ChapterRuntimeStore 访
var textBook: RDEPUBTextBook?
/// 100KB/1000 NSAttributedString
var bookPageMap: RDEPUBBookPageMap?
///
var activeBookmarks: [RDEPUBBookmark] = []
///
var activeHighlights: [RDEPUBHighlight] = []
///
var currentBookIdentifier: String?
///
var paginationToken = UUID()
/// Web
var paginator: RDEPUBPaginator?
///
var searchState: RDEPUBSearchState?
/// BookPageMap
/// map
var pendingFullPageMap: RDEPUBBookPageMap?
///
var lastTextPaginationPageSize: CGSize?
/// OperationQueue
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
/// selectionState
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
@@ -70,38 +56,30 @@ final class RDEPUBReaderContext {
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller
///
var configuration: RDEPUBReaderConfiguration = .default
///
var persistence: RDEPUBReaderPersistence?
/// EPUB URL
var epubURL: URL = URL(string: "about:blank")!
///
var isRepaginating: Bool = false
///
var didStartInitialLoad: Bool = false
///
var isExternalTextBook: Bool = false
/// URL
var textFileURL: URL?
///
var textBookCache = RDEPUBTextBookCache()
// MARK: -
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool = false
var didStartInitialLoad: Bool = false
var isExternalTextBook: Bool = false
var textFileURL: URL?
var textBookCache = RDEPUBTextBookCache()
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
}
// MARK: - 便
/// readerView
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
@@ -115,12 +93,10 @@ final class RDEPUBReaderContext {
)
}
///
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
/// readerView
func currentTextPageSize() -> CGSize {
if Thread.isMainThread {
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
@@ -149,7 +125,6 @@ final class RDEPUBReaderContext {
return dependencies.environment.fallbackViewportSize
}
///
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
@@ -161,7 +136,6 @@ final class RDEPUBReaderContext {
)
}
///
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
@@ -169,7 +143,7 @@ final class RDEPUBReaderContext {
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
//
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
@@ -179,48 +153,39 @@ final class RDEPUBReaderContext {
)
}
///
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
///
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
///
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
///
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
}
///
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
///
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
/// EPUB
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
/// Web
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
/// EPUB
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
}
@@ -252,8 +217,6 @@ final class RDEPUBReaderContext {
)
}
/// 使 contentHash HTML SHA-256
///
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
@@ -262,8 +225,6 @@ final class RDEPUBReaderContext {
)
}
/// 使 contentHash
/// live context
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
@@ -277,7 +238,6 @@ final class RDEPUBReaderContext {
)
}
///
func currentRenderSignature() -> String {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
@@ -311,36 +271,30 @@ final class RDEPUBReaderContext {
}
}
///
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), 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
@@ -349,7 +303,6 @@ final class RDEPUBReaderContext {
return CFAbsoluteTimeGetCurrent() - timestamp
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil }
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
@@ -358,42 +311,34 @@ final class RDEPUBReaderContext {
.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()
}
@@ -1,17 +1,13 @@
// RDEPUBReaderDependencies.swift
// EPUB
import UIKit
///
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
/// 0.0 ~ 1.0
var currentBrightness: CGFloat { get set }
///
var fallbackViewportSize: CGSize { get }
}
/// UIScreen
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
public init() {}
@@ -25,29 +21,20 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
}
}
/// EPUB 便
public struct RDEPUBReaderDependencies {
///
public var environment: any RDEPUBReaderDisplayEnvironment
/// EPUB
public var makeParser: () -> RDEPUBParser
///
public var makePaginator: () -> RDEPUBPaginator
///
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
///
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
///
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
///
/// - Parameters:
/// - environment:
/// - makeParser: EPUB
/// - makePaginator:
/// - makeTextBookBuilder:
/// - makePlainTextBookBuilder:
/// - makeTextRenderer:
public init(
environment: any RDEPUBReaderDisplayEnvironment,
makeParser: @escaping () -> RDEPUBParser,
@@ -64,7 +51,6 @@ public struct RDEPUBReaderDependencies {
self.makeTextRenderer = makeTextRenderer
}
/// 使
public static var live: RDEPUBReaderDependencies {
RDEPUBReaderDependencies(
environment: RDEPUBUIScreenEnvironment(),
@@ -1,11 +1,5 @@
import Foundation
/// EPUB EPUB
///
///
/// -
/// - EPUB Publication
/// -
final class RDEPUBReaderLoadCoordinator {
private unowned let context: RDEPUBReaderContext
@@ -13,7 +7,6 @@ final class RDEPUBReaderLoadCoordinator {
self.context = context
}
///
func startInitialLoadIfNeeded() {
guard let controller = context.controller,
let readerView = context.readerView,
@@ -26,7 +19,6 @@ final class RDEPUBReaderLoadCoordinator {
loadPublication()
}
/// EPUB 线
func loadPublication() {
guard let controller = context.controller else { return }
context.showLoading()
@@ -65,7 +57,6 @@ final class RDEPUBReaderLoadCoordinator {
}
}
/// //
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -1,23 +1,14 @@
import Foundation
/// EPUB
///
///
/// -
/// -
/// -
/// -
final class RDEPUBReaderLocationCoordinator {
private unowned let context: RDEPUBReaderContext
/// spineIndex
private var lastPageChangeSpineIndex: Int?
init(context: RDEPUBReaderContext) {
self.context = context
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -57,13 +48,11 @@ final class RDEPUBReaderLocationCoordinator {
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
// JumpSession
recordPageChangeIfNeeded()
return true
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let readerView = context.readerView else {
@@ -74,8 +63,7 @@ final class RDEPUBReaderLocationCoordinator {
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
return location
}
// resolvedTextLocation nil
// readingSession activePages 退
if let readingSession = context.readingSession,
readingSession.activePages.indices.contains(readerView.currentPage) {
return readingSession.fallbackLocation(
@@ -87,7 +75,6 @@ final class RDEPUBReaderLocationCoordinator {
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
}
///
func persistenceLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else {
@@ -96,7 +83,6 @@ final class RDEPUBReaderLocationCoordinator {
return controller.persistence?.loadLocation(for: currentBookIdentifier)
}
///
func persist(location: RDEPUBLocation) {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else { return }
@@ -106,7 +92,6 @@ final class RDEPUBReaderLocationCoordinator {
controller.updateReaderChrome()
}
/// JumpSession
func recordPageChangeIfNeeded() {
guard let runtime = context.runtime,
let bookPageMap = context.bookPageMap,
@@ -127,7 +112,6 @@ final class RDEPUBReaderLocationCoordinator {
lastPageChangeSpineIndex = currentSpineIndex
// JumpSession
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
currentSpineIndex: currentSpineIndex,
@@ -137,7 +121,6 @@ final class RDEPUBReaderLocationCoordinator {
}
}
///
func resetPageChangeState() {
lastPageChangeSpineIndex = nil
}
@@ -1,17 +1,13 @@
import Foundation
/// EPUB
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private final class MetadataParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
@@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator {
}
private final class MetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
@@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator {
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
}
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
@@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
context.textBook = textBook
@@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
/// Fixed Layout Web
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator {
finishPagination(restoreLocation: restoreLocation)
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
@@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator {
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
/// 使
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
@@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator {
paginatePublication(restoreLocation: restoreLocation)
}
///
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
@@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
@@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
@@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator {
runtime: runtime
)
}
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
runtime: runtime
)
}
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
DispatchQueue.main.async {
@@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator {
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
runtime.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
@@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator {
throw lastError ?? RDEPUBParserError.emptySpine
}
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication,
maxChapterCount: context.configuration.onDemandChapterWindowSize
)
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
}
return chapters
}
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < normalizedMaxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == normalizedMaxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
@@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator {
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
///
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
@@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator {
!cancellationController.isCancelled,
context.paginationToken == token else { return }
// contentHash + SHA-256
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
}
return
}
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
@@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
// 使 spineIndex
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
@@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator {
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,
@@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult
@@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
// 退
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
@@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator {
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
// BackgroundCoverageStore
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
@@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
@@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator {
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,
@@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator {
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
//
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
@@ -1,65 +1,78 @@
import UIKit
/// EPUB
///
/// Facade
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)
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)
///
var isSettingsPanelOpen: Bool = false
///
var needsFullRepaginationAfterSettingsClose: Bool = false
///
private var settingsPreviewGeneration: Int = 0
/// preview
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
/// preview
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() -> RDEPUBReaderTopToolView {
chromeCoordinator.makeTopToolView()
}
///
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
chromeCoordinator.makeBottomToolView()
}
///
func startInitialLoadIfNeeded() {
loadCoordinator.startInitialLoadIfNeeded()
}
///
func reloadBook() {
guard let readerView = context.readerView else { return }
context.didStartInitialLoad = false
@@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime {
startInitialLoadIfNeeded()
}
///
/// - Parameters:
/// - location:
/// - animated:
/// - Returns:
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
@@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime {
return true
}
///
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
@@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime {
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()
}
@@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPresentation(for: page)
}
///
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
///
func presentSettings() {
chromeCoordinator.presentSettings()
}
///
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
///
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
/// Publication
func loadPublication() {
loadCoordinator.loadPublication()
}
/// Publication
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime {
)
}
/// Publication
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
/// TextBook
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
///
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime {
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
// map
// map
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
context.pendingFullPageMap = bookPageMap
}
/// BookPageMap
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
//
guard !controller.isRepaginating else { return }
// 使
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
@@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime {
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
// candidatePageMap
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
break
}
}
///
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
@@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime {
context.pendingFullPageMap = nil
// map
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
// map
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
@@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime {
readerView.reloadPageCountOnly()
}
// JumpSessioncoverage-complete
if let currentLocation,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
@@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime {
}
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
///
func repaginatePreservingCurrentLocation() {
//
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
@@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime {
}
}
/// preview
private func scheduleSettingsPreviewRepagination() {
pendingSettingsPreviewWorkItem?.cancel()
settingsPreviewGeneration += 1
@@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime {
return
}
self.pendingSettingsPreviewWorkItem = nil
let previewAnchor = self.captureSettingsPreviewAnchor()
self.chapterRuntimeStore.invalidateAllForSettingsChange()
self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration)
self.repaginateCurrentChapterOnly(
previewGeneration: previewGeneration,
previewAnchor: previewAnchor
)
}
pendingSettingsPreviewWorkItem = workItem
DispatchQueue.main.asyncAfter(
@@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime {
)
}
/// 使
private func repaginateCurrentChapterOnly(previewGeneration: Int) {
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 }
@@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime {
self.context.replaceActiveSnapshot(self.makeSnapshot(from: 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):
@@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime {
}
}
///
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
@@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime {
settingsPreviewGeneration += 1
}
///
func settingsPanelDidDisappear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
isSettingsPanelOpen = false
settingsPreviewGeneration += 1
//
if needsFullRepaginationAfterSettingsClose {
needsFullRepaginationAfterSettingsClose = false
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
@@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime {
}
}
///
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
/// TextBook
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime {
)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
///
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime {
return false
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
@@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime {
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
@@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime {
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
context.readerView?.reloadData()
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
//
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
@@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime {
return
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
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
}
@@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime {
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
chapterRuntimeStore.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
chapterRuntimeStore.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"initial open prefetch forward spine=\(spineIndex)"
)
chapterLoader.loadChapter(
spineIndex: spineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
func clearOnDemandPageModeState() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange()
@@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime {
backgroundCoverageStore.clearAll()
}
///
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
@@ -893,6 +968,76 @@ final class RDEPUBReaderRuntime {
return builder.build()
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = chapterRuntimeStore.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
)
)
}
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 }
RDEPUBBackgroundTrace.log(
"Runtime",
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
@@ -1,7 +1,7 @@
import Foundation
///
final class RDEPUBReaderSearchCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
@@ -12,8 +12,6 @@ final class RDEPUBReaderSearchCoordinator {
context.controller
}
///
/// - Parameter keyword:
func search(keyword: String) {
guard let controller else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -37,21 +35,16 @@ final class RDEPUBReaderSearchCoordinator {
}
}
///
/// - Returns:
@discardableResult
func searchNext() -> Bool {
advanceSearch(by: 1)
}
///
/// - Returns:
@discardableResult
func searchPrevious() -> Bool {
advanceSearch(by: -1)
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
@@ -66,7 +59,6 @@ final class RDEPUBReaderSearchCoordinator {
return navigateToCurrentSearchMatch(animated: true)
}
///
func clearSearch() {
guard let controller else { return }
controller.searchState = nil
@@ -74,9 +66,6 @@ final class RDEPUBReaderSearchCoordinator {
controller.refreshVisibleContentPreservingLocation()
}
///
/// - Parameter page:
/// - Returns: nil
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
guard let controller else { return nil }
guard let searchState = controller.searchState,
@@ -175,6 +164,7 @@ final class RDEPUBReaderSearchCoordinator {
let progressionDenominator = max(fullLength - 1, 1)
let progression = Double(foundRange.location) / Double(progressionDenominator)
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
matches.append(
RDEPUBSearchMatch(
href: normalizedHref,
@@ -183,7 +173,9 @@ final class RDEPUBReaderSearchCoordinator {
localMatchIndex: localMatchIndex,
rangeLocation: foundRange.location,
rangeLength: foundRange.length,
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
rangeAnchor: rangeAnchor,
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
)
)
@@ -210,6 +202,7 @@ final class RDEPUBReaderSearchCoordinator {
title: runtimeChapter.title,
attributedContent: runtimeChapter.typesetAttributedString,
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
pages: runtimeChapter.pages
)
@@ -266,7 +259,9 @@ final class RDEPUBReaderSearchCoordinator {
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
return controller.restoreReadingLocation(location, animated: animated)
}
@@ -298,7 +293,9 @@ final class RDEPUBReaderSearchCoordinator {
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
rangeAnchor: searchMatch.rangeAnchor,
cfi: searchMatch.cfi,
rangeCFI: searchMatch.rangeCFI
)
if let textBook = controller.textBook, let publication = controller.publication {
@@ -1,30 +1,26 @@
import Foundation
/// UI /
///
/// ChromeCoordinator AnnotationCoordinator
///
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,
@@ -1,13 +1,15 @@
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) {
@@ -18,7 +20,6 @@ final class RDEPUBReaderViewportMonitor {
context.controller
}
///
func viewDidLayoutSubviews() {
guard let controller else { return }
guard let viewportSignature = currentViewportSignature() else { return }
@@ -41,8 +42,6 @@ final class RDEPUBReaderViewportMonitor {
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
}
///
/// - Parameter coordinator:
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
guard let controller else { return }
guard controller.didStartInitialLoad else { return }
@@ -57,7 +56,6 @@ final class RDEPUBReaderViewportMonitor {
}
}
///
func resetForReload() {
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
@@ -65,19 +63,16 @@ final class RDEPUBReaderViewportMonitor {
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
@@ -86,7 +81,6 @@ final class RDEPUBReaderViewportMonitor {
}
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
guard let controller else { return nil }
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
@@ -102,7 +96,6 @@ final class RDEPUBReaderViewportMonitor {
)
}
/// TextBook
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -1,23 +1,16 @@
// RDEPUBSelectionState.swift
//
// view controller
//
import Foundation
///
/// view/controller/coordinator `currentSelection != nil`
enum RDEPUBSelectionState: Equatable {
///
case idle
///
case selecting(anchor: Int)
///
case selected(RDEPUBSelection)
/// // idle
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
/// selecting / selected / committingAction
var hasSelection: Bool {
switch self {
case .idle:
@@ -27,7 +20,6 @@ enum RDEPUBSelectionState: Equatable {
}
}
///
var selection: RDEPUBSelection? {
switch self {
case .idle, .selecting: