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:
+838
@@ -0,0 +1,838 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
|
||||
private struct PendingLoad {
|
||||
var priority: LoadPriority
|
||||
var completions: [LoadCompletion]
|
||||
}
|
||||
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
private let pendingLoadsLock = NSLock()
|
||||
|
||||
private var pendingLoads: [Int: PendingLoad] = [:]
|
||||
|
||||
var onDeferredCFIMapReady: ((Int) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
enum LoadPriority {
|
||||
|
||||
case navigation
|
||||
|
||||
case preview
|
||||
|
||||
case prefetch
|
||||
}
|
||||
|
||||
private enum LoadRegistrationResult {
|
||||
case created
|
||||
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
|
||||
}
|
||||
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
let layoutSnapshot = context?.makeLayoutSnapshot()
|
||||
loadChapterWithSnapshot(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: priority,
|
||||
layoutSnapshot: layoutSnapshot,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private func loadChapterWithSnapshot(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot?,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"memory HIT spine=\(spineIndex) pages=\(cached.pages.count) priority=\(priority)"
|
||||
)
|
||||
if let context {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let completionOnMain: LoadCompletion = { result in
|
||||
DispatchQueue.main.async {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: priority,
|
||||
completion: completionOnMain
|
||||
)
|
||||
|
||||
switch registration {
|
||||
case .joined(let existingPriority, let effectivePriority):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"join pendingLoad spine=\(spineIndex) existing=\(existingPriority) effective=\(effectivePriority)"
|
||||
)
|
||||
return
|
||||
case .created:
|
||||
break
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
_ = store.beginPendingChapterLoad(for: spineIndex)
|
||||
|
||||
store.chapterLoadQueue.async { [weak self] in
|
||||
guard let self, let context = self.context else {
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self?.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
|
||||
return
|
||||
}
|
||||
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
} else {
|
||||
diskSummary = nil
|
||||
}
|
||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
let pageRangeSource: String
|
||||
if precomputedPageRanges != nil {
|
||||
pageRangeSource = "HIT(memoryPageCount)"
|
||||
} else if diskPageRanges != nil {
|
||||
pageRangeSource = "HIT(diskSummary)"
|
||||
} else {
|
||||
pageRangeSource = "MISS(fullRender)"
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build start spine=\(spineIndex) priority=\(queuePriority) pageRanges=\(pageRangeSource)"
|
||||
)
|
||||
let buildStart = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
do {
|
||||
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary,
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build done spine=\(spineIndex) pages=\(chapter.pages.count) elapsedMs=\(Int((CFAbsoluteTimeGetCurrent() - buildStart) * 1000)) pageRanges=\(pageRangeSource)"
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pc = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
|
||||
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
switch effectivePriority {
|
||||
case .navigation:
|
||||
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
self.loadChapterWithSnapshot(spineIndex: target, store: store, priority: .navigation, layoutSnapshot: layoutSnapshot, completion: { _ in })
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .preview:
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .prefetch:
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoad",
|
||||
"build failed spine=\(spineIndex) pageRanges=\(pageRangeSource) error=\(String(describing: error))"
|
||||
)
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let context else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let store else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if store.hasPendingChapterLoad(for: spineIndex) {
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: .navigation
|
||||
) { pendingResult in
|
||||
result = pendingResult
|
||||
semaphore.signal()
|
||||
}
|
||||
if case .joined(let existingPriority, let effectivePriority) = registration {
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
let snapshot = layoutSnapshot ?? context.makeLayoutSnapshot()
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
store.chapterLoadQueue.async {
|
||||
do {
|
||||
let chapter: RDEPUBRuntimeChapter = try autoreleasepool {
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: snapshot)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
} else {
|
||||
diskSummary = nil
|
||||
}
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary,
|
||||
context: context,
|
||||
layoutSnapshot: snapshot
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: chapter.pageRanges,
|
||||
pageCount: chapter.pages.count,
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pageCount, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
return chapter
|
||||
}
|
||||
result = .success(chapter)
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
private func registerPendingLoad(
|
||||
spineIndex: Int,
|
||||
priority: LoadPriority,
|
||||
completion: @escaping LoadCompletion
|
||||
) -> LoadRegistrationResult {
|
||||
pendingLoadsLock.lock()
|
||||
defer { pendingLoadsLock.unlock() }
|
||||
|
||||
if var pending = pendingLoads[spineIndex] {
|
||||
let existingPriority = pending.priority
|
||||
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
|
||||
pending.completions.append(completion)
|
||||
pendingLoads[spineIndex] = pending
|
||||
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
|
||||
}
|
||||
|
||||
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
|
||||
return .created
|
||||
}
|
||||
|
||||
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
|
||||
pendingLoadsLock.lock()
|
||||
let priority = pendingLoads[spineIndex]?.priority
|
||||
pendingLoadsLock.unlock()
|
||||
return priority
|
||||
}
|
||||
|
||||
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
|
||||
pendingLoadsLock.lock()
|
||||
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
|
||||
pendingLoadsLock.unlock()
|
||||
|
||||
guard !completions.isEmpty else { return }
|
||||
|
||||
completions.forEach { $0(result) }
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
let pageSize: CGSize
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
|
||||
if let snapshot = layoutSnapshot {
|
||||
pageSize = snapshot.pageSize
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
} else {
|
||||
assert(Thread.isMainThread, "buildChapter() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: pageRanges,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
return try assembleRuntimeChapter(
|
||||
from: result.chapter,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig,
|
||||
context: context,
|
||||
layoutSnapshot: layoutSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
let rawHTML = try requireHTMLString(parser, href: href)
|
||||
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
let renderer = context.resolvedTextRenderer()
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
|
||||
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
|
||||
let effectivePageRanges: [NSRange]
|
||||
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
|
||||
|
||||
if sanitizedCachedRanges.count == pageRanges.count {
|
||||
effectivePageRanges = sanitizedCachedRanges
|
||||
metadataSource = diskSummary?.pageMetadataList
|
||||
} else {
|
||||
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
|
||||
metadataSource = nil
|
||||
}
|
||||
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: effectivePageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap,
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: effectivePageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
private func buildPagesFromRanges(
|
||||
pageRanges: [NSRange],
|
||||
typesetString: NSAttributedString,
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
title: String,
|
||||
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
|
||||
) -> [RDEPUBTextPage] {
|
||||
let totalPageCount = pageRanges.count
|
||||
return pageRanges.enumerated().map { (pageIndex, range) in
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
isLastPage: pageIndex == totalPageCount - 1
|
||||
)
|
||||
}
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: -1,
|
||||
chapterIndex: 0,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
chapterTitle: title,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: totalPageCount,
|
||||
chapterContent: typesetString,
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + range.length - 1,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
|
||||
guard contentLength > 0 else { return [] }
|
||||
|
||||
return pageRanges.compactMap { range in
|
||||
guard range.location >= 0, range.location < contentLength else {
|
||||
return nil
|
||||
}
|
||||
let maxLength = contentLength - range.location
|
||||
let clampedLength = min(max(range.length, 0), maxLength)
|
||||
guard clampedLength > 0 else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: range.location, length: clampedLength)
|
||||
}
|
||||
}
|
||||
|
||||
private func inferPageMetadata(
|
||||
from string: NSAttributedString,
|
||||
range: NSRange,
|
||||
isLastPage: Bool
|
||||
) -> RDEPUBTextPageMetadata {
|
||||
var attachmentRanges: [NSRange] = []
|
||||
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
|
||||
var blockKinds: [RDEPUBTextBlockKind] = []
|
||||
var semanticHints: [RDEPUBTextSemanticHint] = []
|
||||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
|
||||
var trailingFragmentID: String? = nil
|
||||
|
||||
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
|
||||
attachmentRanges.append(attrRange)
|
||||
attachmentKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||
!blockKinds.contains(kind) {
|
||||
blockKinds.append(kind)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String {
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
for hint in hints where !semanticHints.contains(hint) {
|
||||
semanticHints.append(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
||||
if let rawValue = value as? String,
|
||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||
!attachmentPlacements.contains(placement) {
|
||||
attachmentPlacements.append(placement)
|
||||
}
|
||||
}
|
||||
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
|
||||
if let fid = value as? String {
|
||||
trailingFragmentID = fid
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
return RDEPUBTextPageMetadata(
|
||||
breakReason: isLastPage ? .chapterEnd : .frameLimit,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges,
|
||||
attachmentKinds: attachmentKinds,
|
||||
blockKinds: blockKinds,
|
||||
semanticHints: semanticHints,
|
||||
attachmentPlacements: attachmentPlacements,
|
||||
trailingFragmentID: trailingFragmentID,
|
||||
diagnostics: []
|
||||
)
|
||||
}
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
context: RDEPUBReaderContext,
|
||||
layoutSnapshot: RDEPUBLayoutSnapshot? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
|
||||
cfiMap: chapter.cfiMap,
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context, layoutSnapshot: layoutSnapshot)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: chapter.attributedContent,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pages: chapter.pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext, layoutSnapshot: RDEPUBLayoutSnapshot? = nil) -> RDEPUBChapterCacheKey {
|
||||
let style: RDEPUBTextRenderStyle
|
||||
let layoutConfig: RDEPUBTextLayoutConfig
|
||||
let lineHeightMultiple: CGFloat
|
||||
|
||||
if let snapshot = layoutSnapshot {
|
||||
style = snapshot.style
|
||||
layoutConfig = snapshot.layoutConfig
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
} else {
|
||||
assert(Thread.isMainThread, "makeCacheKey() requires a layoutSnapshot when called off the main thread. Capture a snapshot via makeLayoutSnapshot() before dispatching to a background queue.")
|
||||
let pageSize = context.currentTextPageSize()
|
||||
style = context.currentTextRenderStyle()
|
||||
layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
}
|
||||
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for chapter: RDEPUBRuntimeChapter,
|
||||
cacheKey: RDEPUBChapterCacheKey,
|
||||
store: RDEPUBChapterRuntimeStore
|
||||
) {
|
||||
guard chapter.chapterOffsetMap.cfiMap == nil,
|
||||
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let spineIndex = chapter.spineIndex
|
||||
let href = chapter.href
|
||||
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
|
||||
let chapterText = chapter.chapterOffsetMap.chapterText
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
|
||||
let cfiMap = self.makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText
|
||||
)
|
||||
chapter.updateCFIMap(cfiMap)
|
||||
self.summaryDiskCache?.write(
|
||||
summary: self.makeSummary(
|
||||
for: chapter.pages,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
|
||||
offsetMap: chapter.chapterOffsetMap,
|
||||
cacheKey: cacheKey
|
||||
),
|
||||
for: cacheKey
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.onDeferredCFIMapReady?(spineIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSummary(
|
||||
for pages: [RDEPUBTextPage],
|
||||
fragmentOffsets: [String: Int],
|
||||
offsetMap: RDEPUBChapterOffsetMap,
|
||||
cacheKey: RDEPUBChapterCacheKey
|
||||
) -> RDEPUBChapterSummary {
|
||||
RDEPUBChapterSummary(
|
||||
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: pages.count,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: pages.map { .from($0.metadata) }
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
let href = publication.spine[spineIndex].href
|
||||
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||||
return html.rd_sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
guard let html = parser.htmlString(forRelativePath: href) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func makeCFIMap(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
fragmentOffsets: [String: Int],
|
||||
rawHTML: String?,
|
||||
chapterText: String
|
||||
) -> RDEPUBCFIMap {
|
||||
if let rawHTML {
|
||||
return RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
let domPaths: [String: RDEPUBCFIPath] = [:]
|
||||
let markers = fragmentOffsets
|
||||
.sorted { $0.value < $1.value }
|
||||
.map { fragmentID, offset in
|
||||
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
return RDEPUBCFIMarker(
|
||||
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
return RDEPUBCFIMap(
|
||||
href: href,
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
|
||||
fragmentPathMap: domPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDEPUBChapterLoader.LoadPriority {
|
||||
|
||||
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
|
||||
if lhs.rank >= rhs.rank {
|
||||
return lhs
|
||||
}
|
||||
return rhs
|
||||
}
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .prefetch:
|
||||
return 0
|
||||
case .preview:
|
||||
return 1
|
||||
case .navigation:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
|
||||
case missingParser
|
||||
|
||||
case emptyChapter(spineIndex: Int)
|
||||
|
||||
case emptyChapterHref(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingParser:
|
||||
return "章节加载失败:缺少解析上下文。"
|
||||
case .emptyChapter(let spineIndex):
|
||||
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
|
||||
case .emptyChapterHref(let href):
|
||||
return "章节加载失败:未找到章节资源 \(href)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user