refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs

- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 deletions
@@ -0,0 +1,10 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
}
}
@@ -0,0 +1,112 @@
import Foundation
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
let pageCount: Int
let absolutePageStart: Int
let fragmentOffsets: [String: Int]
}
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
private let indexBySpine: [Int: Int]
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
self.entries = entries
var mapping: [Int: Int] = [:]
for (i, entry) in entries.enumerated() {
mapping[entry.spineIndex] = i
}
self.indexBySpine = mapping
self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
}
static let empty = RDEPUBBookPageMap(entries: [])
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
return entry.absolutePageStart + localPageIndex
}
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if entries[mid].absolutePageStart <= absolutePage {
lo = mid + 1
} else {
hi = mid
}
}
guard lo > 0 else { return nil }
return entries[lo - 1].spineIndex
}
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
let entry = entries[idx]
let local = absolutePage - entry.absolutePageStart
guard local >= 0, local < entry.pageCount else { return nil }
return local
}
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
var totalChapters: Int { entries.count }
struct Builder {
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
}
func build() -> RDEPUBBookPageMap {
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
for item in sorted {
entries.append(RDEPUBBookPageMapEntry(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: item.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: item.fragmentOffsets
))
absolutePageStart += item.pageCount
}
return RDEPUBBookPageMap(entries: entries)
}
}
}
@@ -0,0 +1,12 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}
@@ -0,0 +1,75 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private var accessOrder: [Int] = [] // H-05: LRU tracking for eviction
private let maxEntryCount: Int
private let lock = NSLock()
init(maxEntryCount: Int = 30) {
self.maxEntryCount = maxEntryCount
}
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
get {
lock.lock()
defer { lock.unlock() }
guard let chapter = storage[spineIndex] else {
return nil
}
touchLocked(spineIndex)
return chapter
}
set {
lock.lock()
defer { lock.unlock() }
if let newValue {
storage[spineIndex] = newValue
touchLocked(spineIndex)
// Evict oldest entries if over limit
evictIfNeededLocked()
} else {
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
}
}
var storedSpineIndices: [Int] {
lock.lock()
defer { lock.unlock() }
return Array(storage.keys)
}
func remove(spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: spineIndex)
accessOrder.removeAll { $0 == spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
accessOrder.removeAll()
}
/// H-05: Evict least recently used entries when cache exceeds maxEntryCount.
/// Must be called while holding lock.
private func evictIfNeededLocked() {
while storage.count > maxEntryCount, let oldest = accessOrder.first {
storage.removeValue(forKey: oldest)
accessOrder.removeFirst()
}
}
/// Marks an entry as most recently used.
/// Must be called while holding lock.
private func touchLocked(_ spineIndex: Int) {
accessOrder.removeAll { $0 == spineIndex }
accessOrder.append(spineIndex)
}
}
@@ -0,0 +1,838 @@
import Foundation
final class RDEPUBChapterLoader {
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
private struct PendingLoad {
var priority: LoadPriority
var completions: [LoadCompletion]
}
private weak var context: RDEPUBReaderContext?
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
private let pendingLoadsLock = NSLock()
private var pendingLoads: [Int: PendingLoad] = [:]
var onDeferredCFIMapReady: ((Int) -> Void)?
init(context: RDEPUBReaderContext) {
self.context = context
}
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
summaryDiskCache = cache
}
enum LoadPriority {
case navigation
case preview
case prefetch
}
private enum LoadRegistrationResult {
case created
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
let layoutSnapshot = context?.makeLayoutSnapshot()
loadChapterWithSnapshot(
spineIndex: spineIndex,
store: store,
priority: priority,
layoutSnapshot: layoutSnapshot,
completion: completion
)
}
private func loadChapterWithSnapshot(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
layoutSnapshot: RDEPUBLayoutSnapshot?,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
)
if let context {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
DispatchQueue.main.async {
completion(.success(cached))
}
return
}
let completionOnMain: LoadCompletion = { result in
DispatchQueue.main.async {
completion(result)
}
}
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: priority,
completion: completionOnMain
)
switch registration {
case .joined(let existingPriority, let effectivePriority):
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
)
return
case .created:
break
}
store.markBuilding(true)
_ = store.beginPendingChapterLoad(for: spineIndex)
store.chapterLoadQueue.async { [weak self] in
guard let self, let context = self.context else {
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
return
}
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
let pageRangeSource: String
if precomputedPageRanges != nil {
pageRangeSource = "HIT(memoryPageCount)"
} else if diskPageRanges != nil {
pageRangeSource = "HIT(diskSummary)"
} else {
pageRangeSource = "MISS(fullRender)"
}
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
)
let buildStart = CFAbsoluteTimeGetCurrent()
do {
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary,
context: context,
layoutSnapshot: layoutSnapshot
)
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
)
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pc, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
store.endPendingChapterLoad(for: spineIndex)
switch effectivePriority {
case .navigation:
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
return
}
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .preview:
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
case .prefetch:
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
}
} catch {
RDEPUBBackgroundTrace.log(
"ChapterLoad",
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
)
store.endPendingChapterLoad(for: spineIndex)
store.markBuilding(false)
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
}
}
}
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let context else {
throw RDEPUBChapterLoadError.missingParser
}
if let cached = store?.chapterData(for: spineIndex) {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
store: store
)
}
return cached
}
guard let store else {
throw RDEPUBChapterLoadError.missingParser
}
if store.hasPendingChapterLoad(for: spineIndex) {
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
let registration = registerPendingLoad(
spineIndex: spineIndex,
priority: .navigation
) { pendingResult in
result = pendingResult
semaphore.signal()
}
if case .joined(let existingPriority, let effectivePriority) = registration {
semaphore.wait()
return try result!.get()
}
}
store.assertNotOnChapterLoadQueue()
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
store.chapterLoadQueue.async {
do {
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary,
context: context,
layoutSnapshot: snapshot
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pageCount, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
return chapter
}
result = .success(chapter)
} catch {
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
return try result!.get()
}
private func registerPendingLoad(
spineIndex: Int,
priority: LoadPriority,
completion: @escaping LoadCompletion
) -> LoadRegistrationResult {
pendingLoadsLock.lock()
defer { pendingLoadsLock.unlock() }
if var pending = pendingLoads[spineIndex] {
let existingPriority = pending.priority
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
pending.completions.append(completion)
pendingLoads[spineIndex] = pending
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
}
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
return .created
}
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
pendingLoadsLock.lock()
let priority = pendingLoads[spineIndex]?.priority
pendingLoadsLock.unlock()
return priority
}
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
pendingLoadsLock.lock()
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
pendingLoadsLock.unlock()
guard !completions.isEmpty else { return }
completions.forEach { $0(result) }
}
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize: CGSize
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
if let snapshot = layoutSnapshot {
pageSize = snapshot.pageSize
style = snapshot.style
layoutConfig = snapshot.layoutConfig
} else {
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
}
if let pageRanges = availablePageRanges {
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
pageRanges: pageRanges,
parser: parser,
publication: publication,
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
diskSummary: diskSummary,
context: context
)
}
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
}
return try assembleRuntimeChapter(
from: result.chapter,
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig,
context: context,
layoutSnapshot: layoutSnapshot
)
}
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
diskSummary: RDEPUBChapterSummary? = nil,
context: RDEPUBReaderContext
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
let rawHTML = try requireHTMLString(parser, href: href)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: rawHTML,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let renderer = context.resolvedTextRenderer()
let rendered = try renderer.renderChapter(request: request)
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: typesetString, style: style, layoutConfig: layoutConfig
)
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
let effectivePageRanges: [NSRange]
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
if sanitizedCachedRanges.count == pageRanges.count {
effectivePageRanges = sanitizedCachedRanges
metadataSource = diskSummary?.pageMetadataList
} else {
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
metadataSource = nil
}
let pages = buildPagesFromRanges(
pageRanges: effectivePageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
title: title,
metadataSource: metadataSource
)
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap,
chapterText: typesetString.string
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil,
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: effectivePageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
spineIndex: Int,
href: String,
title: String,
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
) -> [RDEPUBTextPage] {
let totalPageCount = pageRanges.count
return pageRanges.enumerated().map { (pageIndex, range) in
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
metadata = metaList[pageIndex].toPageMetadata()
} else {
metadata = inferPageMetadata(
from: typesetString,
range: range,
isLastPage: pageIndex == totalPageCount - 1
)
}
return RDEPUBTextPage(
absolutePageIndex: -1,
chapterIndex: 0,
spineIndex: spineIndex,
href: href,
chapterTitle: title,
pageIndexInChapter: pageIndex,
totalPagesInChapter: totalPageCount,
chapterContent: typesetString,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + range.length - 1,
metadata: metadata
)
}
}
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
guard contentLength > 0 else { return [] }
return pageRanges.compactMap { range in
guard range.location >= 0, range.location < contentLength else {
return nil
}
let maxLength = contentLength - range.location
let clampedLength = min(max(range.length, 0), maxLength)
guard clampedLength > 0 else {
return nil
}
return NSRange(location: range.location, length: clampedLength)
}
}
private func inferPageMetadata(
from string: NSAttributedString,
range: NSRange,
isLastPage: Bool
) -> RDEPUBTextPageMetadata {
var attachmentRanges: [NSRange] = []
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
var blockKinds: [RDEPUBTextBlockKind] = []
var semanticHints: [RDEPUBTextSemanticHint] = []
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
var trailingFragmentID: String? = nil
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
attachmentRanges.append(attrRange)
attachmentKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!blockKinds.contains(kind) {
blockKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
if let rawValue = value as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
for hint in hints where !semanticHints.contains(hint) {
semanticHints.append(hint)
}
}
}
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!attachmentPlacements.contains(placement) {
attachmentPlacements.append(placement)
}
}
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
if let fid = value as? String {
trailingFragmentID = fid
stop.pointee = true
}
}
return RDEPUBTextPageMetadata(
breakReason: isLastPage ? .chapterEnd : .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
blockKinds: blockKinds,
semanticHints: semanticHints,
attachmentPlacements: attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
context: RDEPUBReaderContext,
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap,
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
sourceAttributedString: nil,
typesetAttributedString: chapter.attributedContent,
layouter: layouter,
pageRanges: pageRanges,
pages: chapter.pages,
chapterOffsetMap: offsetMap
)
}
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
let style: RDEPUBTextRenderStyle
let layoutConfig: RDEPUBTextLayoutConfig
let lineHeightMultiple: CGFloat
if let snapshot = layoutSnapshot {
style = snapshot.style
layoutConfig = snapshot.layoutConfig
lineHeightMultiple = context.configuration.lineHeightMultiple
} else {
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
let pageSize = context.currentTextPageSize()
style = context.currentTextRenderStyle()
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
lineHeightMultiple = context.configuration.lineHeightMultiple
}
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
return RDEPUBChapterCacheKey(
bookID: context.currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
)
}
private func scheduleDeferredCFIMapBuildIfNeeded(
for chapter: RDEPUBRuntimeChapter,
cacheKey: RDEPUBChapterCacheKey,
store: RDEPUBChapterRuntimeStore
) {
guard chapter.chapterOffsetMap.cfiMap == nil,
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
return
}
let spineIndex = chapter.spineIndex
let href = chapter.href
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
let chapterText = chapter.chapterOffsetMap.chapterText
store.chapterLoadQueue.async {
defer { store.endBuildingCFIMap(for: spineIndex) }
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
let chapterText else {
return
}
let cfiMap = self.makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: fragmentOffsets,
rawHTML: rawHTML,
chapterText: chapterText
)
chapter.updateCFIMap(cfiMap)
self.summaryDiskCache?.write(
summary: self.makeSummary(
for: chapter.pages,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
offsetMap: chapter.chapterOffsetMap,
cacheKey: cacheKey
),
for: cacheKey
)
DispatchQueue.main.async {
self.onDeferredCFIMapReady?(spineIndex)
}
}
}
private func makeSummary(
for pages: [RDEPUBTextPage],
fragmentOffsets: [String: Int],
offsetMap: RDEPUBChapterOffsetMap,
cacheKey: RDEPUBChapterCacheKey
) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: pages.count,
fragmentOffsets: fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: pages.map { .from($0.metadata) }
)
}
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }
let href = publication.spine[spineIndex].href
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
return html.rd_sha256Hex
}
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
guard let html = parser.htmlString(forRelativePath: href) else {
throw RDEPUBChapterLoadError.emptyChapterHref(href)
}
return html
}
private func makeCFIMap(
href: String,
spineIndex: Int,
fragmentOffsets: [String: Int],
rawHTML: String?,
chapterText: String
) -> RDEPUBCFIMap {
if let rawHTML {
return RDEPUBCFITextNodeMapBuilder.makeMap(
href: href,
rawHTML: rawHTML,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets
)
}
let domPaths: [String: RDEPUBCFIPath] = [:]
let markers = fragmentOffsets
.sorted { $0.value < $1.value }
.map { fragmentID, offset in
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: href,
fileIndex: spineIndex,
chapterOffset: offset,
fragmentID: fragmentID
)
return RDEPUBCFIMarker(
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
chapterOffset: offset,
fragmentID: fragmentID
)
}
return RDEPUBCFIMap(
href: href,
markers: markers,
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
domFingerprint: "",
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
fragmentPathMap: domPaths
)
)
}
}
private extension RDEPUBChapterLoader.LoadPriority {
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
if lhs.rank >= rhs.rank {
return lhs
}
return rhs
}
var rank: Int {
switch self {
case .prefetch:
return 0
case .preview:
return 1
case .navigation:
return 2
}
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
switch self {
case .missingParser:
return "章节加载失败:缺少解析上下文。"
case .emptyChapter(let spineIndex):
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
case .emptyChapterHref(let href):
return "章节加载失败:未找到章节资源 \(href)"
}
}
}
@@ -0,0 +1,30 @@
import Foundation
public struct RDEPUBChapterLocation: Codable, Equatable {
public var spineIndex: Int
public var chapterOffset: Int
public var fragmentID: String?
public var progressionInChapter: Double?
public var schemaVersion: Int
public init(
spineIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
progressionInChapter: Double? = nil,
schemaVersion: Int = 2
) {
self.spineIndex = spineIndex
self.chapterOffset = chapterOffset
self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
self.progressionInChapter = progressionInChapter
self.schemaVersion = schemaVersion
}
var isFallbackEstimate: Bool { schemaVersion == 1 }
}
@@ -0,0 +1,69 @@
import Foundation
final class RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
private let cfiMapLock = NSLock()
private var _cfiMap: RDEPUBCFIMap?
var cfiMap: RDEPUBCFIMap? {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
return _cfiMap
}
let chapterText: String?
init(
fragmentOffsets: [String: Int],
pageStartOffsets: [Int],
pageEndOffsets: [Int],
cfiMap: RDEPUBCFIMap?,
chapterText: String?
) {
self.fragmentOffsets = fragmentOffsets
self.pageStartOffsets = pageStartOffsets
self.pageEndOffsets = pageEndOffsets
self._cfiMap = cfiMap
self.chapterText = chapterText
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
cfiMapLock.lock()
_cfiMap = cfiMap
cfiMapLock.unlock()
}
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
func chapterOffset(forCFI rawCFI: String?) -> Int? {
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
let resolved = RDEPUBCFIResolver.resolve(cfi)
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
return RDEPUBCFIRecoveryEngine.recover(
cfi: cfi,
cfiMap: cfiMap,
chapterText: chapterText,
fragmentOffsets: fragmentOffsets,
fallbackOffset: resolved.chapterOffset,
lastOffset: lastOffset
)?.chapterOffset
}
func pageIndex(forChapterOffset offset: Int) -> Int? {
for i in 0..<pageStartOffsets.count {
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
return i
}
}
return nil
}
}
@@ -0,0 +1,203 @@
import UIKit
final class RDEPUBChapterRuntimeStore {
private let chapterDataCache = RDEPUBChapterDataCache()
private let pageCountCache = RDEPUBPageCountCache()
let imageCache = NSCache<NSString, UIImage>()
let chapterLoadQueue = DispatchQueue(label: "com.RDEpubReader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// M-02: currentSpineIndex and windowSpineIndices are accessed only from the main thread
// (verified by audit of all 7 access points). They are not protected by locks unlike
// navigationLock/prefetchLock/buildingLock/cfiMapLock, but this is safe as long as
// access remains main-thread-only. Do NOT access from chapterLoadQueue.
private(set) var currentSpineIndex: Int?
private(set) var windowSpineIndices: [Int] = []
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
private var buildingCFIMapSpineIndices: Set<Int> = []
private let cfiMapLock = NSLock()
private var pendingChapterLoadSpineIndices: Set<Int> = []
private let pendingChapterLoadLock = NSLock()
init() {
imageCache.countLimit = 50
imageCache.totalCostLimit = 104_857_600 // 100 MB
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
func assertNotOnChapterLoadQueue() {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
return pageCountCache[key]
}
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
RDEPUBMemoryProbe.log("chapterLoaded spine=\(chapter.spineIndex) pages=\(chapter.pages.count)")
}
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
pageCountCache[key] = pc
}
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let radius = max(0, windowRadius)
let lowerBound = max(0, spineIndex - radius)
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
guard lowerBound <= upperBound else {
windowSpineIndices = [spineIndex]
return
}
windowSpineIndices = Array(lowerBound...upperBound)
}
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
}
func evictAllExceptCurrent() {
guard let current = currentSpineIndex else {
chapterDataCache.removeAll()
pageCountCache.removeAll()
return
}
let currentChapter = chapterDataCache[current]
chapterDataCache.removeAll()
if let ch = currentChapter {
chapterDataCache[current] = ch
}
pageCountCache.removeAll()
}
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
pendingNavigationTarget = nil
navigationLock.unlock()
return target
}
func addPrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.insert(spineIndex)
prefetchLock.unlock()
}
func removePrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.remove(spineIndex)
prefetchLock.unlock()
}
func clearPrefetchTargets() {
prefetchLock.lock()
pendingPrefetchTargets.removeAll()
prefetchLock.unlock()
}
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
prefetchLock.lock()
let has = pendingPrefetchTargets.contains(spineIndex)
prefetchLock.unlock()
return has
}
func markBuilding(_ building: Bool) {
buildingLock.lock()
isBuilding = building
buildingLock.unlock()
}
func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
defer { pendingChapterLoadLock.unlock() }
return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
}
func endPendingChapterLoad(for spineIndex: Int) {
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.remove(spineIndex)
pendingChapterLoadLock.unlock()
}
func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
pendingChapterLoadLock.lock()
let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
pendingChapterLoadLock.unlock()
return hasPendingLoad
}
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
return inserted
}
func endBuildingCFIMap(for spineIndex: Int) {
cfiMapLock.lock()
buildingCFIMapSpineIndices.remove(spineIndex)
cfiMapLock.unlock()
}
func invalidateAllLayoutDependentContent() {
RDEPUBMemoryProbe.log("layoutDependentContentInvalidateAll")
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
cfiMapLock.lock()
buildingCFIMapSpineIndices.removeAll()
cfiMapLock.unlock()
pendingChapterLoadLock.lock()
pendingChapterLoadSpineIndices.removeAll()
pendingChapterLoadLock.unlock()
}
}
@@ -0,0 +1,228 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.RDEpubReader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
self.cacheDirectory = cacheDirectory
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.async {
self.writeImmediately(summary: summary, for: key)
}
}
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.sync {
self.writeImmediately(summary: summary, for: key)
}
}
func flushPendingWrites() {
queue.sync { }
}
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
} else {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
}
return nil
}
do {
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
return nil
}
}
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
) {
var summaries: [Int: RDEPUBChapterSummary] = [:]
var mapBuilder = RDEPUBBookPageMap.Builder()
for item in keys {
if let summary = read(for: item.key) {
summaries[item.spineIndex] = summary
mapBuilder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
}
return (summaries, mapBuilder)
}
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
return false
}
}
return true
}
func removeAll() {
removeFiles(matching: { _ in true })
}
func removeAll(forBookID bookID: String) {
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
removeFiles { $0.hasPrefix(bookPrefix + "__") }
}
func removeAll(forRenderSignature renderSignature: String) {
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
removeFiles { $0.contains(renderPrefix) }
}
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return (0, 0)
}
var count = 0
var totalBytes: Int64 = 0
for fileURL in files where fileURL.pathExtension == "json" {
count += 1
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalBytes += Int64(size)
}
}
return (count, totalBytes)
}
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.rd_sha256Hex
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key)
let tmpURL = fileURL.appendingPathExtension("tmp")
do {
let data = try JSONEncoder().encode(summary)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
try? fileManager.removeItem(at: tmpURL)
}
}
private func removeFiles(matching predicate: (String) -> Bool) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.rd_sha256Hex.prefix(12).lowercased()
}
}
struct RDEPUBChapterSummary: Codable {
let pageRanges: [RangeData]
let pageCount: Int
let fragmentOffsets: [String: Int]
let cfiMap: RDEPUBCFIMap?
let renderSignature: String
let schemaVersion: Int
let chapterContentHash: String
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 17
struct RangeData: Codable {
let location: Int
let length: Int
var nsRange: NSRange { NSRange(location: location, length: length) }
}
struct PageMetadataSummary: Codable {
let breakReason: String
let attachmentRanges: [RangeData]
let attachmentKinds: [String]
let blockKinds: [String]
let semanticHints: [String]
let attachmentPlacements: [String]
let trailingFragmentID: String?
func toPageMetadata() -> RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges.map { $0.nsRange },
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
PageMetadataSummary(
breakReason: metadata.breakReason.rawValue,
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
blockKinds: metadata.blockKinds.map { $0.rawValue },
semanticHints: metadata.semanticHints.map { $0.rawValue },
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
trailingFragmentID: metadata.trailingFragmentID
)
}
}
}
@@ -0,0 +1,679 @@
import Foundation
final class RDEPUBChapterWarmupOrchestrator {
private unowned let context: RDEPUBReaderContext
private unowned let store: RDEPUBChapterRuntimeStore
private unowned let loader: RDEPUBChapterLoader
private unowned let presentationRuntime: RDEPUBPresentationRuntime
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private let refreshVisibleContentPreservingLocation: () -> Void
private let asyncLoadStateLock = NSLock()
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
private var isExtendingPartialBookPageMap = false
private let prepareRequestStateLock = NSLock()
private var pendingPreparePageNumbers: Set<Int> = []
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
private static let upcomingChapterLookaheadCount = 2
init(
context: RDEPUBReaderContext,
store: RDEPUBChapterRuntimeStore,
loader: RDEPUBChapterLoader,
presentationRuntime: RDEPUBPresentationRuntime,
locationCoordinator: RDEPUBReaderLocationCoordinator,
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
jumpSessionManager: RDEPUBJumpSessionManager,
refreshVisibleContentPreservingLocation: @escaping () -> Void
) {
self.context = context
self.store = store
self.loader = loader
self.presentationRuntime = presentationRuntime
self.locationCoordinator = locationCoordinator
self.backgroundPriorityManager = backgroundPriorityManager
self.jumpSessionManager = jumpSessionManager
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
}
@discardableResult
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
guard let bookPageMap = context.bookPageMap,
let publication = context.publication else {
return false
}
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
let chapterReady = store.chapterData(for: spineIndex) != nil
if let debouncedResult = debouncedPrepareResult(
pageNumber: pageNumber,
spineIndex: spineIndex,
chapterReady: chapterReady,
allowSynchronousLoad: allowSynchronousLoad
) {
return debouncedResult
}
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
if !chapterReady {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
spineIndex: spineIndex,
triggerPageNumber: pageNumber,
completion: completion
)
return false
}
do {
_ = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
} catch {
clearPendingPreparePageNumber(pageNumber)
return false
}
}
markPrepareResolved(pageNumber)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
return true
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap else {
return
}
let buildableSpineIndices = buildableSpineIndices(in: publication)
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
let isNearStart = currentPageNumber <= minimumTrailingPages
var spineIndicesToAppend: [Int] = []
if isNearEnd {
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
} else if isNearStart {
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
} else {
return
}
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
return
}
let currentLocation = locationCoordinator.currentVisibleLocation()
let loadedChaptersLock = NSLock()
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
let group = DispatchGroup()
for spineIndex in spineIndicesToAppend {
group.enter()
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .prefetch
) { result in
defer { group.leave() }
switch result {
case .success(let chapter):
loadedChaptersLock.lock()
loadedChapters[spineIndex] = chapter
loadedChaptersLock.unlock()
case .failure:
break
}
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
defer { self.endPartialBookPageMapExtension() }
// Use live position values rather than the stale captured values,
// since the user may have turned several pages since the extension began.
let livePageNumber = max(self.context.readerView?.currentPage ?? 0, 0) + 1
let liveLocation = self.locationCoordinator.currentVisibleLocation()
self.applyAsyncPartialBookPageMapExtension(
currentPageNumber: livePageNumber,
currentLocation: liveLocation,
currentMap: currentMap,
loadedChapters: loadedChapters
)
}
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
store.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if store.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
store.addPrefetchTarget(spineIndex)
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
@discardableResult
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
guard context.bookPageMap != nil,
let publication = context.publication,
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
return false
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
} else {
false
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
if context.pendingPageMapUpdates.contains(where: { update in
update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
}) {
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableIndices = buildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else { return false }
store.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
context.readerView?.reloadData()
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
}
func clear() {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.removeAll()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
prepareRequestStateLock.lock()
pendingPreparePageNumbers.removeAll()
recentPrepareTimestamps.removeAll()
prepareRequestStateLock.unlock()
}
private func applyAsyncPartialBookPageMapExtension(
currentPageNumber: Int,
currentLocation: RDEPUBLocation?,
currentMap: RDEPUBBookPageMap,
loadedChapters: [Int: RDEPUBRuntimeChapter]
) {
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
guard let chapter = loadedChapters[spineIndex] else { return nil }
return RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
guard !appendedEntries.isEmpty else {
return
}
let combinedEntries = (currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
var absolutePageStart = 0
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
let normalized = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalized
}
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
presentationRuntime.queueExtendedPartialPageMap(
newMap,
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
}
private func scheduleAsynchronousChapterPreparation(
spineIndex: Int,
triggerPageNumber: Int,
completion: ((Bool) -> Void)?
) {
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
return
}
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .preview
) { [weak self] result in
guard let self else { return }
self.endAsynchronousChapterPreparation(for: spineIndex)
switch result {
case .success:
self.markPrepareResolved(triggerPageNumber)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
case .failure(let error):
self.clearPendingPreparePageNumber(triggerPageNumber)
completion?(false)
}
}
}
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
// Keep chapters that maybePrefetchUpcomingChapters is responsible for,
// otherwise the two policies evict/rebuild the same chapter in a loop.
let retainedLookaheadIndices = upcomingLookaheadSpineIndices(after: spineIndex)
for evictable in store.evictableSpineIndices() where !retainedLookaheadIndices.contains(evictable) {
store.evict(spineIndex: evictable)
}
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
store.addPrefetchTarget(adjacentSpineIndex)
loader.loadChapter(
spineIndex: adjacentSpineIndex,
store: store,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func upcomingLookaheadSpineIndices(after spineIndex: Int) -> Set<Int> {
guard let publication = context.publication else { return [] }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return [] }
return Set(buildableIndices.dropFirst(currentPosition + 1).prefix(Self.upcomingChapterLookaheadCount))
}
private func maybePrefetchUpcomingChapters(
aroundAbsolutePageNumber pageNumber: Int,
in bookPageMap: RDEPUBBookPageMap,
threshold: Int = 3,
lookaheadChapterCount: Int = RDEPUBChapterWarmupOrchestrator.upcomingChapterLookaheadCount
) {
guard let publication = context.publication else { return }
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex) else {
return
}
let remainingPages = chapter.pages.count - localPageIndex - 1
guard remainingPages <= threshold else { return }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
for targetSpineIndex in targets {
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
store.addPrefetchTarget(targetSpineIndex)
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
minimumTrailingPages: Int = 2
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
return
}
let currentPageNumber = max(readerView.currentPage + 1, 1)
let trailingPages = currentMap.totalPages - currentPageNumber
guard trailingPages <= minimumTrailingPages else { return }
let buildableIndices = buildableSpineIndices(in: publication)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
var projectedTotalPages = currentMap.totalPages
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = store.chapterData(for: spineIndex) else { break }
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
projectedTotalPages += chapter.pages.count
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
break
}
}
guard !appendedEntries.isEmpty else { return }
let existingEntries = currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
}
var absolutePageStart = 0
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
let normalizedEntry = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalizedEntry
}
let newMap = RDEPUBBookPageMap(entries: newEntries)
guard newMap.totalPages > currentMap.totalPages else { return }
presentationRuntime.queueForwardAppendedPageMap(newMap)
}
private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
guard store.chapterData(for: spineIndex) == nil else { return false }
guard !store.hasPrefetchTarget(spineIndex) else { return false }
guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
return true
}
private func debouncedPrepareResult(
pageNumber: Int,
spineIndex: Int,
chapterReady: Bool,
allowSynchronousLoad: Bool
) -> Bool? {
prepareRequestStateLock.lock()
defer { prepareRequestStateLock.unlock() }
let now = CFAbsoluteTimeGetCurrent()
recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
if !allowSynchronousLoad && !chapterReady {
let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
if !inserted {
return false
}
return nil
}
pendingPreparePageNumbers.remove(pageNumber)
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
now - lastTimestamp <= prepareRequestDebounceInterval {
return chapterReady
}
recentPrepareTimestamps[pageNumber] = now
return nil
}
private func markPrepareResolved(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
prepareRequestStateLock.unlock()
}
private func clearPendingPreparePageNumber(_ pageNumber: Int) {
prepareRequestStateLock.lock()
pendingPreparePageNumbers.remove(pageNumber)
prepareRequestStateLock.unlock()
}
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
guard let readerView = context.readerView,
let bookPageMap = context.bookPageMap else {
return
}
if readerView.isPageCurlTransitioning {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.refreshVisibleContentIfNeeded(
afterPreparing: spineIndex,
triggerPageNumber: triggerPageNumber
)
}
return
}
let visiblePageNumber = readerView.currentPage + 1
if visiblePageNumber == triggerPageNumber {
refreshVisibleContentPreservingLocation()
return
}
guard visiblePageNumber > 0,
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
visibleSpineIndex == spineIndex else {
return
}
refreshVisibleContentPreservingLocation()
}
// Intentional synchronous path: called from TOC distant jumps where
// the user expects immediate navigation. The loading indicator is shown
// by the caller. Do NOT convert to async without UX consideration.
private func loadPartialWindowChapters(
around anchorPosition: Int,
in buildableSpineIndices: [Int],
targetSpineIndex: Int,
windowSize: Int
) -> [RDEPUBRuntimeChapter] {
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
let startIndex = max(0, upperBound - max(windowSize, 1))
let window = Array(buildableSpineIndices[startIndex..<upperBound])
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in window {
do {
let chapter = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
chapters.append(chapter)
} catch {
if spineIndex == targetSpineIndex {
return []
}
}
}
return chapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
}
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
}
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.remove(spineIndex)
asyncLoadStateLock.unlock()
}
private func beginPartialBookPageMapExtension() -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
guard !isExtendingPartialBookPageMap else { return false }
isExtendingPartialBookPageMap = true
return true
}
private func endPartialBookPageMapExtension() {
asyncLoadStateLock.lock()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
}
}
@@ -0,0 +1,33 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
get {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
set {
lock.lock()
defer { lock.unlock() }
storage[key] = newValue
}
}
func remove(forSpineIndex spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage = storage.filter { $0.value.spineIndex != spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
}
}
@@ -0,0 +1,40 @@
import Foundation
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
final class RDEPUBPageResolver {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
self.context = context
self.store = store
}
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
guard let bookPageMap = context.bookPageMap,
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex),
chapter.pages.indices.contains(localPageIndex),
let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
return nil
}
var page = chapter.pages[localPageIndex]
page.absolutePageIndex = absolutePageIndex
page.chapterIndex = chapterIndex
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
}
}
@@ -0,0 +1,52 @@
import Foundation
final class RDEPUBRuntimeChapter {
let spineIndex: Int
let href: String
let title: String
var sourceAttributedString: NSAttributedString?
let typesetAttributedString: NSAttributedString
let layouter: RDEPUBTextLayouter
let pageRanges: [NSRange]
let pages: [RDEPUBTextPage]
let chapterOffsetMap: RDEPUBChapterOffsetMap
init(
spineIndex: Int,
href: String,
title: String,
sourceAttributedString: NSAttributedString?,
typesetAttributedString: NSAttributedString,
layouter: RDEPUBTextLayouter,
pageRanges: [NSRange],
pages: [RDEPUBTextPage],
chapterOffsetMap: RDEPUBChapterOffsetMap
) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.sourceAttributedString = sourceAttributedString
self.typesetAttributedString = typesetAttributedString
self.layouter = layouter
self.pageRanges = pageRanges
self.pages = pages
self.chapterOffsetMap = chapterOffsetMap
}
func releaseSourceText() {
sourceAttributedString = nil
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
chapterOffsetMap.updateCFIMap(cfiMap)
}
}
@@ -0,0 +1,14 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}