feat: complete large-book pagination runtime and UI coverage
This commit is contained in:
@@ -107,6 +107,31 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 根据 EPUB 位置计算对应的页码
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let publication,
|
||||
let bookPageMap = readerContext.bookPageMap,
|
||||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
let localPageIndex: Int
|
||||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||||
localPageIndex = summary.pageRanges.firstIndex {
|
||||
let range = $0.nsRange
|
||||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
} else {
|
||||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
}
|
||||
return bookPageMap.absolutePageIndex(
|
||||
spineIndex: spineIndex,
|
||||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
if let textBook, let publication {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
@@ -135,6 +160,47 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 根据页码解析对应的文本位置
|
||||
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
|
||||
let startOffset = resolvedPage.page.pageStartOffset
|
||||
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
|
||||
let fragmentID = nearestFragmentID(
|
||||
beforeOrAt: startOffset,
|
||||
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: resolvedPage.page.href,
|
||||
progression: Double(startOffset) / Double(chapterLength),
|
||||
lastProgression: Double(endOffset) / Double(chapterLength),
|
||||
fragment: fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(
|
||||
start: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: startOffset,
|
||||
fragmentID: fragmentID
|
||||
),
|
||||
end: RDEPUBTextAnchor(
|
||||
fileIndex: resolvedPage.page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: endOffset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
)
|
||||
)
|
||||
if let publication {
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
guard let textBook,
|
||||
let publication,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
@@ -154,6 +220,17 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
/// 同步文本阅读状态到阅读会话(页码、位置、spine、章节等)
|
||||
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
|
||||
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
|
||||
readingSession?.updateReadingContext(
|
||||
pageNumber: pageNumber,
|
||||
location: location,
|
||||
spineIndex: resolvedPage.page.spineIndex,
|
||||
chapterIndex: resolvedPage.chapterIndex,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
readingSession?.transition(to: .idle)
|
||||
@@ -184,4 +261,37 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
|
||||
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
|
||||
}
|
||||
|
||||
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor.chapterOffset
|
||||
}
|
||||
if let fragment = location.fragment,
|
||||
let offset = fallbackEntry.fragmentOffsets[fragment] {
|
||||
return offset
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
|
||||
guard pageCount > 1 else { return 0 }
|
||||
return min(
|
||||
pageCount - 1,
|
||||
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
|
||||
)
|
||||
}
|
||||
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = Int.min
|
||||
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
|
||||
bestOffset = fragmentOffset
|
||||
bestID = fragmentID
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,28 @@ import UIKit
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
/// 返回阅读器总页数
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
textBook?.pages.count ?? activePages.count
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
contentView.configure(
|
||||
page: resolvedPage.page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
}
|
||||
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
@@ -53,7 +70,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
textBook == nil
|
||||
(textBook == nil && readerContext.bookPageMap == nil)
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
@@ -94,13 +111,17 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
|
||||
}
|
||||
|
||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||
if totalPages > 0, pageNum == totalPages - 1 {
|
||||
delegate?.epubReaderDidReachEnd(self)
|
||||
}
|
||||
|
||||
if textBook != nil,
|
||||
if (textBook != nil || readerContext.bookPageMap != nil),
|
||||
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
|
||||
persist(location: location)
|
||||
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
|
||||
@@ -123,7 +144,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil,
|
||||
guard textBook != nil || readerContext.bookPageMap != nil,
|
||||
!isRepaginating,
|
||||
!isReconcilingTextPaginationSize,
|
||||
pageNum >= 0,
|
||||
@@ -147,7 +168,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isReconcilingTextPaginationSize = false
|
||||
guard self.textBook != nil, !self.isRepaginating else { return }
|
||||
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
|
||||
self.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,18 @@ extension RDEPUBReaderController {
|
||||
/// 获取当前页的原生文本语义摘要,用于调试和可访问性
|
||||
/// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
guard let textBook,
|
||||
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
|
||||
let resolvedPage: RDEPUBTextPage?
|
||||
if let textBook {
|
||||
resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first
|
||||
} else if readerContext.bookPageMap != nil {
|
||||
let absolutePageIndex = max(readerView.currentPage, 0)
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1)
|
||||
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page
|
||||
} else {
|
||||
resolvedPage = nil
|
||||
}
|
||||
|
||||
guard let page = resolvedPage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -242,4 +252,3 @@ extension RDEPUBReaderController {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ extension RDEPUBReaderController {
|
||||
hideLoading()
|
||||
readerContext.clearActiveSnapshot()
|
||||
textBook = nil
|
||||
readerContext.bookPageMap = nil
|
||||
readerView.reloadData()
|
||||
errorLabel.text = error.localizedDescription
|
||||
errorLabel.isHidden = false
|
||||
|
||||
@@ -13,18 +13,12 @@ import Foundation
|
||||
extension RDEPUBReaderController {
|
||||
/// 解析当前阅读位置对应的目录项,按页码或 href 匹配
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContents
|
||||
let items = flattenedTableOfContentsItems(
|
||||
from: publication?.tableOfContents ?? [],
|
||||
includePageNumbers: false
|
||||
)
|
||||
guard !items.isEmpty else { return nil }
|
||||
|
||||
let currentPageNumber = max(readerView.currentPage + 1, 1)
|
||||
let pageAnchoredMatch = items.last { item in
|
||||
guard let pageNumber = item.pageNumber else { return false }
|
||||
return pageNumber <= currentPageNumber
|
||||
}
|
||||
if let pageAnchoredMatch {
|
||||
return pageAnchoredMatch
|
||||
}
|
||||
|
||||
guard let publication,
|
||||
let currentLocation = currentVisibleLocation(),
|
||||
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
|
||||
@@ -60,28 +54,12 @@ extension RDEPUBReaderController {
|
||||
/// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0
|
||||
depth: Int = 0,
|
||||
includePageNumbers: Bool = true
|
||||
) -> [RDEPUBReaderTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||
let pageNumber: Int?
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
pageNumber = chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
} else if let readingSession {
|
||||
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
} else {
|
||||
pageNumber = nil
|
||||
}
|
||||
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
|
||||
|
||||
let current = RDEPUBReaderTableOfContentsItem(
|
||||
title: item.title,
|
||||
@@ -89,8 +67,53 @@ extension RDEPUBReaderController {
|
||||
depth: depth,
|
||||
pageNumber: pageNumber
|
||||
)
|
||||
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
|
||||
return [current] + flattenedTableOfContentsItems(
|
||||
from: item.children,
|
||||
depth: depth + 1,
|
||||
includePageNumbers: includePageNumbers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let publication,
|
||||
let bookPageMap = readerContext.bookPageMap,
|
||||
let spineIndex = readerContext.normalizedSpineIndex(for: location),
|
||||
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
let localPageIndex: Int
|
||||
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
|
||||
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
|
||||
localPageIndex = summary.pageRanges.firstIndex {
|
||||
let range = $0.nsRange
|
||||
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
|
||||
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
} else {
|
||||
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
|
||||
}
|
||||
return bookPageMap.absolutePageIndex(
|
||||
spineIndex: spineIndex,
|
||||
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
if let textBook, let publication,
|
||||
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
return chapterData.pageNumber(for: normalizedLocation)
|
||||
?? textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
private var isRetryingPendingDemoPage = false
|
||||
private let maxPendingDemoPageAttempts = 24
|
||||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||||
private var demoStateTimer: Timer?
|
||||
private var lastEmittedDemoState = ""
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@@ -81,7 +83,17 @@ public final class RDURLReaderController: UIViewController {
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
emitDemoState()
|
||||
startDemoStateTimerIfNeeded()
|
||||
refreshDemoState()
|
||||
}
|
||||
|
||||
public override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
@@ -155,8 +167,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
||||
numberOfColumns: 1,
|
||||
columnGap: 20,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85
|
||||
@@ -205,15 +217,17 @@ public final class RDURLReaderController: UIViewController {
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
|
||||
let knownPages = readerController.readerContext.bookPageMap?.totalPages
|
||||
?? readerController.textBook?.pages.count
|
||||
?? readerController.activePages.count
|
||||
let numPages = readerController.readerView.numberOfPages()
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||||
guard pageNumber <= totalPages else { return false }
|
||||
|
||||
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
return true
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): knownPages=\(knownPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||||
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): moved=\(moved), after currentPage=\(readerController.readerView.currentPage)")
|
||||
if moved {
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||||
@@ -288,19 +302,87 @@ public final class RDURLReaderController: UIViewController {
|
||||
])
|
||||
}
|
||||
|
||||
private func startDemoStateTimerIfNeeded() {
|
||||
guard demoStateTimer == nil else { return }
|
||||
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.refreshDemoState(logIfChanged: false)
|
||||
}
|
||||
if let demoStateTimer {
|
||||
RunLoop.main.add(demoStateTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDemoStateTimer() {
|
||||
demoStateTimer?.invalidate()
|
||||
demoStateTimer = nil
|
||||
}
|
||||
|
||||
private func emitDemoState(prefix: String? = nil) {
|
||||
refreshDemoState(logPrefix: prefix, logIfChanged: true)
|
||||
}
|
||||
|
||||
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
|
||||
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
|
||||
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
|
||||
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
|
||||
let highlights = readerController?.highlights.count ?? 0
|
||||
let selection = readerController?.currentSelection == nil ? 0 : 1
|
||||
let state = "reader=opened page=\(page) display=\(display) toolbar=\(toolbar) highlights=\(highlights) selection=\(selection)"
|
||||
let location = readerController?.currentLocation
|
||||
let href = encodedDemoLocationHref(location?.href)
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||
let state = [
|
||||
"reader=opened",
|
||||
"page=\(page)",
|
||||
"display=\(display)",
|
||||
"toolbar=\(toolbar)",
|
||||
"highlights=\(highlights)",
|
||||
"selection=\(selection)",
|
||||
"href=\(href)",
|
||||
"progression=\(progression)",
|
||||
"mode=\(mapSnapshot.mode)",
|
||||
"pagination=\(mapSnapshot.phase)",
|
||||
"knownPages=\(mapSnapshot.knownPages)",
|
||||
"knownChapters=\(mapSnapshot.knownChapters)",
|
||||
"buildableChapters=\(mapSnapshot.buildableChapters)",
|
||||
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)"
|
||||
].joined(separator: " ")
|
||||
demoStateLabel.text = state
|
||||
if let prefix {
|
||||
logDemoState(prefix: prefix)
|
||||
} else {
|
||||
if let logPrefix {
|
||||
logDemoState(prefix: logPrefix)
|
||||
} else if logIfChanged, state != lastEmittedDemoState {
|
||||
print("[ReadViewDemo] automation \(state)")
|
||||
}
|
||||
lastEmittedDemoState = state
|
||||
}
|
||||
|
||||
private func encodedDemoLocationHref(_ href: String?) -> String {
|
||||
guard let href, !href.isEmpty else { return "nil" }
|
||||
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
||||
}
|
||||
|
||||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||||
guard let readerController else {
|
||||
return ("unavailable", "none", 0, 0, 0)
|
||||
}
|
||||
|
||||
if let bookPageMap = readerController.readerContext.bookPageMap {
|
||||
let buildableChapters = readerController.publication?.spine.filter {
|
||||
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
|
||||
}.count ?? 0
|
||||
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
|
||||
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
|
||||
}
|
||||
|
||||
if let textBook = readerController.textBook {
|
||||
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
|
||||
}
|
||||
|
||||
let snapshotChapters = readerController.activeChapters.count
|
||||
let snapshotPages = readerController.activePages.count
|
||||
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸。
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
let threadName = resolvedThreadName()
|
||||
let queueLabel = resolvedQueueLabel()
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
|
||||
}
|
||||
|
||||
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
|
||||
let startedAt = CFAbsoluteTimeGetCurrent()
|
||||
log(scope, "START \(message)")
|
||||
do {
|
||||
let result = try work()
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
|
||||
return result
|
||||
} catch {
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolvedThreadName() -> String {
|
||||
if let name = Thread.current.name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
return "main"
|
||||
}
|
||||
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
|
||||
}
|
||||
|
||||
private static func resolvedQueueLabel() -> String {
|
||||
String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// BookPageMap 中的单个条目,记录一个章节的轻量元数据。
|
||||
/// 不持有 NSAttributedString,每条约 100 字节。
|
||||
struct RDEPUBBookPageMapEntry {
|
||||
let spineIndex: Int
|
||||
let href: String
|
||||
let title: String
|
||||
/// 该章节的页数
|
||||
let pageCount: Int
|
||||
/// 该章节在全书中的绝对起始页码(从 0 开始)
|
||||
let absolutePageStart: Int
|
||||
/// fragment ID → 字符偏移量映射
|
||||
let fragmentOffsets: [String: Int]
|
||||
}
|
||||
|
||||
/// 全书轻量页码映射:仅存储每章的页数和起始位置,
|
||||
/// 内存成本约 100 字节/章,1000 章 ≈ 100KB。
|
||||
///
|
||||
/// 提供 spineIndex ↔ 绝对页码的双向查询,
|
||||
/// 用于进度条、目录跳转、位置恢复等不依赖内容的场景。
|
||||
struct RDEPUBBookPageMap {
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
/// 按 spineIndex 索引的查找表
|
||||
private let indexBySpine: [Int: Int] // spineIndex -> entries 数组下标
|
||||
/// 全书总页数
|
||||
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: [])
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// spineIndex + 本地页码 → 全书绝对页码
|
||||
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
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → spineIndex
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
|
||||
// 二分查找:entries 按 absolutePageStart 有序
|
||||
var lo = 0, hi = entries.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
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
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的条目
|
||||
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
return entries[idx]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 在 entries 中的章节序号。
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
|
||||
indexBySpine[spineIndex]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的页数
|
||||
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
|
||||
entry(forSpineIndex: spineIndex)?.pageCount
|
||||
}
|
||||
|
||||
/// 全书总章节数
|
||||
var totalChapters: Int { entries.count }
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// Builder:从各章的 pageCount 逐步构建 BookPageMap
|
||||
struct Builder {
|
||||
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
|
||||
|
||||
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
|
||||
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
|
||||
}
|
||||
|
||||
func build() -> RDEPUBBookPageMap {
|
||||
// 按 spineIndex 排序
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
let bookID: String
|
||||
let spineIndex: Int
|
||||
let renderSignature: String
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterDataCache {
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage[spineIndex]
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage[spineIndex] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
storage.removeAll()
|
||||
}
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
// MARK: - 请求优先级
|
||||
|
||||
enum LoadPriority {
|
||||
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
||||
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
||||
}
|
||||
|
||||
// MARK: - 主入口:加载单个章节
|
||||
|
||||
/// 在 chapterLoadQueue 上构建单章,完成后回调到主线程
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
// 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致)
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行,
|
||||
// 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程
|
||||
store.markBuilding(true)
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
|
||||
// 仅当内存级 pageCountCache 未命中时才查磁盘摘要
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||||
if diskSummary != nil {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)")
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)")
|
||||
}
|
||||
} else {
|
||||
diskSummary = nil
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)")
|
||||
}
|
||||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
do {
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
) {
|
||||
try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
|
||||
// 5. 回填缓存
|
||||
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)
|
||||
|
||||
// 6. 按优先级处理完成逻辑
|
||||
switch priority {
|
||||
case .navigation:
|
||||
// 前台导航:检查是否有更新的导航目标(§14.4 取消语义)
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
// 当前结果不再是用户目标,丢弃,转而加载新目标
|
||||
store.markBuilding(false)
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
|
||||
case .prefetch:
|
||||
// 后台预取:仅回填缓存,标记预取目标完成
|
||||
// 不触发跳章,不检查导航目标队列
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
||||
|
||||
/// 约束:
|
||||
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
||||
/// - 不允许恢复整书 RDEPUBTextBook
|
||||
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
||||
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
||||
/// - 只在首次迁移且目标章未命中缓存时使用
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let store else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)")
|
||||
store.chapterLoadQueue.async {
|
||||
do {
|
||||
result = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"sync buildChapter spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
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
|
||||
)
|
||||
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)
|
||||
return .success(chapter)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
pageRanges: pageRanges,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 完整路径:无缓存,走全量渲染 + 分页 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title ?? ""
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
|
||||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: try requireHTMLString(parser, href: href),
|
||||
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
|
||||
)
|
||||
|
||||
// 2. metadata 来源策略:
|
||||
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
||||
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
||||
let metadataSource = diskSummary?.pageMetadataList
|
||||
|
||||
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: pageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
// 4. 构建 layouter(用于后续可能的重新分页场景)
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
// 5. 构建 chapterOffsetMap
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset }
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
||||
|
||||
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 pageContent = typesetString.attributedSubstring(from: range)
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
// 从摘要缓存恢复完整 metadata
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
// 无缓存 metadata,从 attributedString 属性推断
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
isLastPage: pageIndex == totalPageCount - 1
|
||||
)
|
||||
}
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: -1,
|
||||
chapterIndex: 0,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
chapterTitle: title,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: totalPageCount,
|
||||
chapterContent: typesetString,
|
||||
content: pageContent,
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + range.length - 1,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
||||
|
||||
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: []
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
) 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 }
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
// 回填磁盘摘要(P2 阶段生效)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
summaryDiskCache?.write(summary: summary, 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
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
||||
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> 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.sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
guard let html = parser.htmlString(forRelativePath: href) else {
|
||||
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||||
}
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
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)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
/// 章节级位置模型——单章内的精确定位
|
||||
/// 取代旧版 RDEPUBLocation 的全局 progression 方式
|
||||
public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
/// 章节在 spine 中的索引
|
||||
public var spineIndex: Int
|
||||
/// 章内字符偏移(从章首算起,0-based)
|
||||
public var chapterOffset: Int
|
||||
/// HTML fragment ID(如章节内锚点 #section1)
|
||||
public var fragmentID: String?
|
||||
/// 章内 progression(可选,fragmentID 优先时为 nil)
|
||||
public var progressionInChapter: Double?
|
||||
/// schema 版本:1=粗估降级, 2=精确值
|
||||
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
|
||||
}
|
||||
|
||||
/// 粗估结果标记:schemaVersion == 1 表示 chapterOffset 由 progression 粗估得来
|
||||
var isFallbackEstimate: Bool { schemaVersion == 1 }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterOffsetMap {
|
||||
let fragmentOffsets: [String: Int]
|
||||
let pageStartOffsets: [Int]
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
/// fragmentID -> 章内字符偏移
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
/// 章内字符偏移 -> 章内页码(从 0 开始)
|
||||
func pageIndex(forChapterOffset offset: Int) -> Int? {
|
||||
for i in 0..<pageStartOffsets.count {
|
||||
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
// MARK: - 子缓存
|
||||
|
||||
/// 章节运行时主缓存(等价 WXRead chapterDataCache)
|
||||
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||
|
||||
/// 轻量分页结构缓存(等价 WXRead pageCountCache)
|
||||
private let pageCountCache = RDEPUBPageCountCache()
|
||||
|
||||
/// 图片缓存(独立 NSCache,等价 WXRead imageCache)
|
||||
let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
||||
/// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成
|
||||
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// MARK: - 窗口状态
|
||||
|
||||
/// 当前章 spineIndex
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
/// 当前窗口内的 spineIndex 集合(当前 + prev + next)
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
|
||||
|
||||
/// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
private var pendingNavigationTarget: Int?
|
||||
private let navigationLock = NSLock()
|
||||
|
||||
/// 后台预取目标集合(±1 相邻章预取)
|
||||
/// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章
|
||||
private var pendingPrefetchTargets: Set<Int> = []
|
||||
private let prefetchLock = NSLock()
|
||||
|
||||
/// 是否有章节正在构建中
|
||||
private(set) var isBuilding: Bool = false
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init() {
|
||||
imageCache.countLimit = 50
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
func assertNotOnChapterLoadQueue() {
|
||||
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||
}
|
||||
|
||||
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
return chapterDataCache[spineIndex]
|
||||
}
|
||||
|
||||
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
return pageCountCache[key]
|
||||
}
|
||||
|
||||
// MARK: - 缓存插入
|
||||
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||
chapterDataCache[chapter.spineIndex] = chapter
|
||||
}
|
||||
|
||||
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
|
||||
pageCountCache[key] = pc
|
||||
}
|
||||
|
||||
// MARK: - 窗口管理
|
||||
|
||||
/// 设定当前章,自动计算 ±1 窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) {
|
||||
currentSpineIndex = spineIndex
|
||||
var window = [spineIndex]
|
||||
if spineIndex > 0 { window.append(spineIndex - 1) }
|
||||
if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) }
|
||||
windowSpineIndices = window
|
||||
}
|
||||
|
||||
/// 返回窗口外、应该淘汰的 spineIndex
|
||||
func evictableSpineIndices() -> [Int] {
|
||||
let windowSet = Set(windowSpineIndices)
|
||||
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - 淘汰
|
||||
|
||||
func evict(spineIndex: Int) {
|
||||
chapterDataCache.remove(spineIndex: spineIndex)
|
||||
pageCountCache.remove(forSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// WXRead 语义:内存警告时 pageCountCache 全量清空
|
||||
pageCountCache.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - 内存警告
|
||||
|
||||
func handleMemoryWarning() {
|
||||
evictAllExceptCurrent()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
|
||||
// MARK: - 前台导航请求管理(§14.4 取消语义)
|
||||
|
||||
/// 注册前台导航目标(用户主动跳章时调用)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
func setNavigationTarget(spineIndex: Int) {
|
||||
navigationLock.lock()
|
||||
pendingNavigationTarget = spineIndex
|
||||
navigationLock.unlock()
|
||||
}
|
||||
|
||||
/// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标)
|
||||
func consumeNavigationTarget() -> Int? {
|
||||
navigationLock.lock()
|
||||
let target = pendingNavigationTarget
|
||||
pendingNavigationTarget = nil
|
||||
navigationLock.unlock()
|
||||
return target
|
||||
}
|
||||
|
||||
// MARK: - 后台预取请求管理
|
||||
|
||||
/// 注册后台预取目标(±1 相邻章预取时调用)
|
||||
/// 预取不抢占前台导航通道
|
||||
func addPrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.insert(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 标记预取目标已完成
|
||||
func removePrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.remove(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 清空所有预取目标(切章时调用,旧预取结果不再需要)
|
||||
func clearPrefetchTargets() {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.removeAll()
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 检查是否有待处理的预取目标
|
||||
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
|
||||
prefetchLock.lock()
|
||||
let has = pendingPrefetchTargets.contains(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
return has
|
||||
}
|
||||
|
||||
func markBuilding(_ building: Bool) {
|
||||
buildingLock.lock()
|
||||
isBuilding = building
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - P1: 排版参数变化后整体失效(§8.5)
|
||||
|
||||
func invalidateAllForSettingsChange() {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterSummaryDiskCache {
|
||||
private let cacheDirectory: URL
|
||||
private let fileManager = FileManager.default
|
||||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
self.cacheDirectory = cacheDirectory
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - 写入(异步)
|
||||
|
||||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.sync {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
|
||||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||||
/// 同步方法,应在后台线程调用。
|
||||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||||
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
|
||||
}
|
||||
|
||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
isCacheComplete(keys: keys)
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - key -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data = try? JSONEncoder().encode(summary)
|
||||
try? data?.write(to: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBChapterSummary: Codable {
|
||||
let pageRanges: [RangeData]
|
||||
let pageCount: Int
|
||||
let fragmentOffsets: [String: Int]
|
||||
let renderSignature: String
|
||||
let schemaVersion: Int
|
||||
let chapterContentHash: String
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
private let loader: RDEPUBChapterLoader
|
||||
|
||||
/// 当前窗口快照
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
/// 窗口切换回调
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
/// 章节加载完成后恢复位置用
|
||||
private var restoreChapterOffset: Int?
|
||||
|
||||
// MARK: - 打开书籍
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
store.setCurrentChapter(spineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
self.restoreChapterOffset = restoreChapterOffset
|
||||
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: targetSpineIndex)
|
||||
// 清空旧预取目标
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
// 加载目标章(前台导航优先级)
|
||||
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
}
|
||||
|
||||
/// 加载章节,如果当前章节失败则自动尝试下一个可渲染的章节
|
||||
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
|
||||
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.isSwitchingChapter = false
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
self.store.setCurrentChapter(spineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
self.store.setNavigationTarget(spineIndex: nextIndex)
|
||||
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
} else {
|
||||
// 所有章节都不可渲染
|
||||
self.isSwitchingChapter = false
|
||||
self.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 构建窗口快照
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||
return
|
||||
}
|
||||
let prev = current > 0 ? store.chapterData(for: current - 1) : nil
|
||||
let next = store.chapterData(for: current + 1)
|
||||
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(
|
||||
currentChapter: chapter,
|
||||
previousChapter: prev,
|
||||
nextChapter: next
|
||||
)
|
||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
isApplyingSnapshot = false
|
||||
|
||||
// 首次打开时恢复到指定 chapterOffset
|
||||
if let offset = restoreChapterOffset,
|
||||
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
|
||||
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||
let targetPage = snapshot.anchorPageOffset + pageIndex
|
||||
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
|
||||
} else if snapshot.pageCount > 0 {
|
||||
// 首次打开且没有恢复位置时,必须显式落到当前章首屏。
|
||||
// reloadData() 内部 switchReaderDisplayType 已将 currentPage 从 -1 置为 0,
|
||||
// 但 0 不一定是目标章的起始页(anchorPageOffset),仍需显式 transition。
|
||||
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
|
||||
}
|
||||
restoreChapterOffset = nil
|
||||
|
||||
// 预取 ±1
|
||||
prefetchAdjacent(current: current)
|
||||
}
|
||||
|
||||
// MARK: - 预取(后台优先级,不抢占前台导航通道)
|
||||
|
||||
private func prefetchAdjacent(current: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 预取 prev
|
||||
if current > 0 && store.chapterData(for: current - 1) == nil {
|
||||
let prevIndex = current - 1
|
||||
store.addPrefetchTarget(prevIndex)
|
||||
loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
// 预取 next
|
||||
if current < totalSpineCount - 1 && store.chapterData(for: current + 1) == nil {
|
||||
let nextIndex = current + 1
|
||||
store.addPrefetchTarget(nextIndex)
|
||||
loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻章
|
||||
|
||||
/// 到达章末,翻到下一章
|
||||
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex else { return }
|
||||
let next = current + 1
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
guard next < totalSpineCount else { return }
|
||||
|
||||
flipToChapter(spineIndex: next, completion: completion)
|
||||
}
|
||||
|
||||
/// 到达章首,翻到上一章
|
||||
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex, current > 0 else { return }
|
||||
flipToChapter(spineIndex: current - 1, completion: completion)
|
||||
}
|
||||
|
||||
/// 跳转到指定章节(目录/书签/搜索)
|
||||
func flipToChapter(
|
||||
spineIndex: Int,
|
||||
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
|
||||
) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: spineIndex)
|
||||
// 清空后台预取目标
|
||||
store.clearPrefetchTargets()
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 先淘汰旧窗口外章节
|
||||
store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount)
|
||||
let evictable = store.evictableSpineIndices()
|
||||
for idx in evictable {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
// 如果目标章已在缓存中,直接构建快照
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
buildSnapshotAroundCurrent(chapter: cached)
|
||||
isSwitchingChapter = false
|
||||
if let snap = currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 未命中缓存,走加载链路(前台导航优先级)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
self.isSwitchingChapter = false
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
if let snap = self.currentSnapshot {
|
||||
completion(.success(snap))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 刷新快照(预取完成后调用)
|
||||
|
||||
func refreshSnapshot() {
|
||||
guard let current = store.currentSpineIndex,
|
||||
let currentChapter = store.chapterData(for: current) else { return }
|
||||
|
||||
// 空闲门槛检查
|
||||
guard isReaderIdle() else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshSnapshot()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prev = current > 0 ? store.chapterData(for: current - 1) : nil
|
||||
let next = store.chapterData(for: current + 1)
|
||||
|
||||
let newSnapshot = RDEPUBChapterWindowSnapshot.from(
|
||||
currentChapter: currentChapter,
|
||||
previousChapter: prev,
|
||||
nextChapter: next
|
||||
)
|
||||
|
||||
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
|
||||
currentSnapshot = newSnapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(newSnapshot)
|
||||
isApplyingSnapshot = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - P1: 翻章后维护窗口
|
||||
|
||||
/// 当前章常驻,预取新的相邻章
|
||||
func maintainWindow(afterMovingTo spineIndex: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 淘汰窗口外章节
|
||||
store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount)
|
||||
for idx in store.evictableSpineIndices() {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
// 预取 prev
|
||||
if spineIndex > 0 && store.chapterData(for: spineIndex - 1) == nil {
|
||||
let prevIndex = spineIndex - 1
|
||||
store.addPrefetchTarget(prevIndex)
|
||||
loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
// 预取 next
|
||||
if spineIndex < totalSpineCount - 1 && store.chapterData(for: spineIndex + 1) == nil {
|
||||
let nextIndex = spineIndex + 1
|
||||
store.addPrefetchTarget(nextIndex)
|
||||
loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self = self, case .success = result else { return }
|
||||
self.refreshSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 内部辅助
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
if self.currentSnapshot == nil {
|
||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshotContentChanged(
|
||||
old: RDEPUBChapterWindowSnapshot?,
|
||||
new: RDEPUBChapterWindowSnapshot
|
||||
) -> Bool {
|
||||
guard let old = old else { return true }
|
||||
|
||||
if old.chapters.count != new.chapters.count { return true }
|
||||
|
||||
let oldSpines = old.chapters.map { $0.spineIndex }
|
||||
let newSpines = new.chapters.map { $0.spineIndex }
|
||||
if oldSpines != newSpines { return true }
|
||||
|
||||
if old.pageCount != new.pageCount { return true }
|
||||
|
||||
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
|
||||
if oldCh.pages.count != newCh.pages.count { return true }
|
||||
}
|
||||
|
||||
if old.anchorChapterIndex != new.anchorChapterIndex
|
||||
|| old.anchorPageOffset != new.anchorPageOffset { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isReaderIdle() -> Bool {
|
||||
guard !store.isBuilding else { return false }
|
||||
guard !isSwitchingChapter else { return false }
|
||||
guard !isApplyingSnapshot else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切章进行中标记
|
||||
private var isSwitchingChapter: Bool = false
|
||||
/// 应用快照进行中标记
|
||||
private var isApplyingSnapshot: Bool = false
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
/// 窗口中的章节(有序:prev, current, next)
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||
/// 窗口内页码不写回 RDEPUBTextPage 模型;
|
||||
/// flattenedPages 的数组下标就是窗口内连续页码(从 0 开始)。
|
||||
let flattenedPages: [RDEPUBTextPage]
|
||||
|
||||
/// 当前章在 chapters 数组中的索引
|
||||
let anchorChapterIndex: Int
|
||||
|
||||
/// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号)
|
||||
let anchorPageOffset: Int
|
||||
|
||||
/// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射
|
||||
let windowStartSpineIndex: Int
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// 从章节窗口构建快照
|
||||
static func from(
|
||||
currentChapter: RDEPUBRuntimeChapter,
|
||||
previousChapter: RDEPUBRuntimeChapter?,
|
||||
nextChapter: RDEPUBRuntimeChapter?
|
||||
) -> RDEPUBChapterWindowSnapshot {
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
var anchorIndex = 0
|
||||
var pageOffset = 0
|
||||
|
||||
if let prev = previousChapter {
|
||||
chapters.append(prev)
|
||||
anchorIndex = 1
|
||||
pageOffset = prev.pages.count
|
||||
}
|
||||
|
||||
chapters.append(currentChapter)
|
||||
|
||||
if let next = nextChapter {
|
||||
chapters.append(next)
|
||||
}
|
||||
|
||||
// 展平页数组
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in chapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
page.chapterIndex = chIdx
|
||||
allPages.append(page)
|
||||
}
|
||||
}
|
||||
|
||||
let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex
|
||||
|
||||
return RDEPUBChapterWindowSnapshot(
|
||||
chapters: chapters,
|
||||
flattenedPages: allPages,
|
||||
anchorChapterIndex: anchorIndex,
|
||||
anchorPageOffset: pageOffset,
|
||||
windowStartSpineIndex: windowStartSpineIndex
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节
|
||||
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
var offset = 0
|
||||
for ch in chapters {
|
||||
if flattenedPageIndex < offset + ch.pages.count {
|
||||
return ch
|
||||
}
|
||||
offset += ch.pages.count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex
|
||||
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||
}
|
||||
|
||||
/// 总页数(窗口内)
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBLocationConverter {
|
||||
|
||||
// MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换
|
||||
|
||||
/// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation
|
||||
/// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换
|
||||
/// 仅在无法获取章节长度时才降级到粗估 fallback
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
chapterLengthProvider: ((Int) -> Int?)? = nil
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 1. 从 href 找到 spineIndex
|
||||
guard let spineItem = publication.spine.first(where: {
|
||||
$0.href == location.href || $0.href.contains(location.href)
|
||||
}) else { return nil }
|
||||
|
||||
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
|
||||
|
||||
// 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响)
|
||||
if let fragmentID = location.fragment {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: location.progression
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 有 chapterLength 时做精确转换
|
||||
if let provider = chapterLengthProvider,
|
||||
let chapterLength = provider(spineIndex), chapterLength > 0 {
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback:无法获取章节长度时的粗估(仅作临时降级)
|
||||
let estimatedOffset = Int(location.progression * 10000)
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: estimatedOffset,
|
||||
fragmentID: nil,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖
|
||||
)
|
||||
}
|
||||
|
||||
/// 精确转换:已知章节实际长度
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBChapterLocation? {
|
||||
let offset = Int(location.progression * Double(chapterLength))
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: location.fragment,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径)
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
chapter: RDEPUBRuntimeChapter
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 优先用 fragmentID
|
||||
if let fragmentID = location.fragment,
|
||||
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterOffset: fragmentOffset,
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: nil,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
// 用 progression + 真实长度
|
||||
let chapterLength = chapter.typesetAttributedString.length
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
/// 新版 -> 旧版(兼容外部接口)
|
||||
static func toLegacy(
|
||||
chapterLocation: RDEPUBChapterLocation,
|
||||
href: String,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBLocation {
|
||||
let progression = chapterLength > 0
|
||||
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
|
||||
: 0
|
||||
return RDEPUBLocation(
|
||||
href: href,
|
||||
progression: min(max(progression, 0), 1),
|
||||
fragment: chapterLocation.fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
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 entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
|
||||
}
|
||||
|
||||
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,35 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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
|
||||
}
|
||||
|
||||
/// 释放 sourceAttributedString 以降低内存
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRuntimePageCount {
|
||||
let cacheKey: RDEPUBChapterCacheKey
|
||||
let spineIndex: Int
|
||||
let pageRanges: [NSRange]
|
||||
let pageCount: Int
|
||||
let renderSignature: String
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
var sha256Hex: String {
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,8 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
readerView.dataSource = context.controller as? RDReaderDataSource
|
||||
readerView.delegate = context.controller as? RDReaderDelegate
|
||||
readerView.dataSource = context.controller
|
||||
readerView.delegate = context.controller
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
||||
@@ -117,7 +117,10 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
let items = controller.flattenedTableOfContents
|
||||
let items = controller.flattenedTableOfContentsItems(
|
||||
from: controller.publication?.tableOfContents ?? [],
|
||||
includePageNumbers: false
|
||||
)
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let chapterController = RDEPUBReaderChapterListController(
|
||||
|
||||
@@ -34,7 +34,10 @@ final class RDEPUBReaderContext {
|
||||
/// 当前阅读会话,管理页面和章节状态。
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
/// 原生文本排版生成的图书模型(仅文本重排模式)。
|
||||
/// 章节模式下为 nil,内容通过 ChapterRuntimeStore 访问。
|
||||
var textBook: RDEPUBTextBook?
|
||||
/// 全书轻量页码映射(章节模式)。约 100KB/1000章,不持有 NSAttributedString。
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
/// 当前书籍的所有书签。
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
/// 当前书籍的所有高亮标注。
|
||||
@@ -131,8 +134,9 @@ final class RDEPUBReaderContext {
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
// 小说正文更看重尽量铺满页面,避免页尾出现明显留白。
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85,
|
||||
@@ -186,6 +190,66 @@ final class RDEPUBReaderContext {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
|
||||
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (currentBookIdentifier ?? "default").sha256Hex
|
||||
let directory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash: String
|
||||
if let parser,
|
||||
let publication,
|
||||
publication.spine.indices.contains(spineIndex) {
|
||||
let href = publication.spine[spineIndex].href
|
||||
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
)
|
||||
}
|
||||
|
||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||
}
|
||||
|
||||
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let publication else { return nil }
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) ?? location
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return publication.spine.firstIndex {
|
||||
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
/// 工厂方法:创建纯文本图书构建器。
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
|
||||
@@ -25,7 +25,16 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook == nil {
|
||||
if context.bookPageMap != nil {
|
||||
guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
|
||||
return false
|
||||
}
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
)
|
||||
} else if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
@@ -44,7 +53,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
let readerView = context.readerView else {
|
||||
return nil
|
||||
}
|
||||
if context.textBook != nil, readerView.currentPage >= 0 {
|
||||
if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
|
||||
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
|
||||
}
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
|
||||
+296
-311
@@ -4,56 +4,12 @@ import Foundation
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 后台构建文本图书模型并应用分页快照
|
||||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
private let backgroundChapterPause: TimeInterval = 0.04
|
||||
private let incrementalMergeChapterThreshold = 20
|
||||
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
|
||||
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
|
||||
private let stagedBookRequestLock = NSLock()
|
||||
private var stagedBookRequest: StagedBookRequest?
|
||||
|
||||
private struct QuickBuildState {
|
||||
var quickWindow: [Int]
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var book: RDEPUBTextBook
|
||||
}
|
||||
|
||||
private struct StagedBookRequest {
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var orderedSpineIndices: [Int]
|
||||
var restoreLocation: RDEPUBLocation?
|
||||
var isComplete: Bool
|
||||
}
|
||||
|
||||
private final class IncrementalChapterStore {
|
||||
private let lock = NSLock()
|
||||
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
|
||||
|
||||
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
|
||||
lock.lock()
|
||||
builtChapters[spineIndex] = chapter
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func contains(_ spineIndex: Int) -> Bool {
|
||||
lock.lock()
|
||||
let contains = builtChapters[spineIndex] != nil
|
||||
lock.unlock()
|
||||
return contains
|
||||
}
|
||||
|
||||
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
|
||||
lock.lock()
|
||||
let chapters = builtChapters
|
||||
lock.unlock()
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -61,7 +17,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排走 TextBookBuilder,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@@ -78,7 +34,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
|
||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@@ -122,10 +78,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
clearStagedBookRequest()
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -144,6 +98,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
) {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -163,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
@@ -203,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -212,184 +167,136 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
let context = self.context
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
let quickSpineCandidates = prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
var didApplyQuickChapter = false
|
||||
guard context.controller != nil else { return }
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
guard prioritizedCandidates.first != nil else {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
|
||||
let quickBuildState = try self.buildQuickTextBook(
|
||||
builder: builder,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle,
|
||||
prioritizedCandidates: quickSpineCandidates
|
||||
guard context.controller != nil,
|
||||
let runtime = context.runtime else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
|
||||
)
|
||||
|
||||
if let quickBook = quickBuildState?.book {
|
||||
DispatchQueue.main.sync {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
didApplyQuickChapter = true
|
||||
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadFirstRenderableRuntimeChapter"
|
||||
) {
|
||||
try self.loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: prioritizedCandidates,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
|
||||
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
|
||||
var pendingIncrementalCount = 0
|
||||
let incrementalBuildOrder = self.incrementalBuildOrder(
|
||||
allBuildableIndices: allBuildableIndices,
|
||||
quickWindow: quickBuildState?.quickWindow ?? []
|
||||
)
|
||||
|
||||
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||||
) {
|
||||
try self.loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapterStore.insert(result.chapter, for: spineIndex)
|
||||
pendingIncrementalCount += 1
|
||||
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
|
||||
|
||||
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
|
||||
pendingIncrementalCount = 0
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: false
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
}
|
||||
}
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: true
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
runtime.chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
if didApplyQuickChapter {
|
||||
self.context.isRepaginating = false
|
||||
self.context.hideLoading()
|
||||
} else {
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildQuickTextBook(
|
||||
builder: RDEPUBTextBookBuilder,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
prioritizedCandidates: [Int]
|
||||
) throws -> QuickBuildState? {
|
||||
var anchorResult: RDEPUBTextChapterBuildResult?
|
||||
for spineIndex in prioritizedCandidates {
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
), !result.chapter.pages.isEmpty else {
|
||||
continue
|
||||
private func loadFirstRenderableRuntimeChapter(
|
||||
prioritizedSpineIndices: [Int],
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
var lastError: Error?
|
||||
for spineIndex in prioritizedSpineIndices {
|
||||
do {
|
||||
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
} catch {
|
||||
lastError = error
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
anchorResult = result
|
||||
break
|
||||
}
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
guard let anchorResult else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let quickWindow = quickWindowSpineIndices(
|
||||
around: anchorResult.chapter.spineIndex,
|
||||
private func loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> [RDEPUBRuntimeChapter] {
|
||||
let windowSpineIndices = initialWindowSpineIndices(
|
||||
around: anchorSpineIndex,
|
||||
in: publication
|
||||
)
|
||||
|
||||
let chapterStore = IncrementalChapterStore()
|
||||
for spineIndex in quickWindow {
|
||||
let result: RDEPUBTextChapterBuildResult?
|
||||
if spineIndex == anchorResult.chapter.spineIndex {
|
||||
result = anchorResult
|
||||
} else {
|
||||
result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in windowSpineIndices {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == anchorSpineIndex {
|
||||
throw error
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
|
||||
guard let chapter = result?.chapter,
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
chapterStore.insert(chapter, for: spineIndex)
|
||||
}
|
||||
|
||||
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
|
||||
return QuickBuildState(
|
||||
quickWindow: quickWindow,
|
||||
chapterStore: chapterStore,
|
||||
book: book
|
||||
)
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func prioritizedBuildableSpineIndices(
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) -> [Int] {
|
||||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||||
return publication.spine.indices
|
||||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
.sorted { lhs, rhs in
|
||||
abs(lhs - preferred) < abs(rhs - preferred)
|
||||
}
|
||||
}
|
||||
|
||||
private func quickWindowSpineIndices(
|
||||
private func initialWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
@@ -399,7 +306,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected: [Int] = [anchorSpineIndex]
|
||||
var selected = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
@@ -422,131 +329,209 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return selected
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
builder.add(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func prioritizedBuildableSpineIndices(
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) -> [Int] {
|
||||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||||
return publication.spine.indices
|
||||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
.sorted { lhs, rhs in
|
||||
abs(lhs - preferred) < abs(rhs - preferred)
|
||||
}
|
||||
}
|
||||
|
||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
func scheduleStagedIncrementalTextBookApplication() {
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.applyStagedIncrementalTextBookIfPossible()
|
||||
}
|
||||
stagedIncrementalApplyWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
|
||||
}
|
||||
|
||||
private func applyStagedIncrementalTextBookIfPossible() {
|
||||
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
|
||||
!context.isRepaginating,
|
||||
context.readingSession?.navigatorState == .idle else {
|
||||
scheduleStagedIncrementalTextBookApplication()
|
||||
return
|
||||
}
|
||||
|
||||
guard let staged = consumeStagedBookRequest() else {
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
guard let stagedBook = textBook(
|
||||
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
|
||||
orderedBy: staged.orderedSpineIndices
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
|
||||
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
|
||||
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
|
||||
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
private func waitForReadingInteractionToSettle() {
|
||||
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
|
||||
while context.controller != nil,
|
||||
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private func incrementalBuildOrder(
|
||||
allBuildableIndices: [Int],
|
||||
quickWindow: [Int]
|
||||
) -> [Int] {
|
||||
guard let firstWindowIndex = quickWindow.first,
|
||||
let lastWindowIndex = quickWindow.last else {
|
||||
return allBuildableIndices
|
||||
}
|
||||
|
||||
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
|
||||
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
|
||||
return quickWindow + forward + backward
|
||||
}
|
||||
|
||||
private func textBook(
|
||||
from builtChapters: [Int: RDEPUBTextChapter],
|
||||
orderedBy spineIndices: [Int]
|
||||
) -> RDEPUBTextBook? {
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var pages: [RDEPUBTextPage] = []
|
||||
|
||||
for spineIndex in spineIndices {
|
||||
guard var chapter = builtChapters[spineIndex],
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapter.chapterIndex = chapters.count
|
||||
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
|
||||
var page = page
|
||||
page.absolutePageIndex = pages.count + localPageIndex
|
||||
page.chapterIndex = chapters.count
|
||||
page.pageIndexInChapter = localPageIndex
|
||||
page.totalPagesInChapter = chapter.pages.count
|
||||
return page
|
||||
}
|
||||
chapter.pages = normalizedPages
|
||||
chapters.append(chapter)
|
||||
pages.append(contentsOf: normalizedPages)
|
||||
}
|
||||
|
||||
guard !pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBTextBook(chapters: chapters, pages: pages)
|
||||
}
|
||||
|
||||
private func stageBookRequest(
|
||||
chapterStore: IncrementalChapterStore,
|
||||
orderedSpineIndices: [Int],
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
isComplete: Bool
|
||||
) {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = StagedBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: orderedSpineIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: isComplete
|
||||
)
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
private func consumeStagedBookRequest() -> StagedBookRequest? {
|
||||
stagedBookRequestLock.lock()
|
||||
defer { stagedBookRequestLock.unlock() }
|
||||
let request = stagedBookRequest
|
||||
stagedBookRequest = nil
|
||||
return request
|
||||
}
|
||||
|
||||
private func clearStagedBookRequest() {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = nil
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
// MARK: - 元数据专用解析(Phase 0)
|
||||
|
||||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||||
/// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。
|
||||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return }
|
||||
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard context.controller != nil else { return }
|
||||
let catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
let restored = summaryDiskCache?.readAll(keys: catalog)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count)"
|
||||
)
|
||||
var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder()
|
||||
var lastAppliedCount = 0
|
||||
let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys)
|
||||
|
||||
if !cachedSpineIndices.isEmpty {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
||||
)
|
||||
let cachedMap = mapBuilder.build()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
||||
}
|
||||
}
|
||||
|
||||
for (offset, spineIndex) in allBuildableIndices.enumerated() {
|
||||
guard context.controller != nil,
|
||||
context.paginationToken == token else {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
||||
return
|
||||
}
|
||||
if cachedSpineIndices.contains(spineIndex) {
|
||||
continue
|
||||
}
|
||||
self.waitForReadingInteractionToSettle(using: context)
|
||||
do {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)")
|
||||
let lightweightEntry = try RDEPUBBackgroundTrace.measure(
|
||||
"MetadataParse",
|
||||
"spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> RDEPUBBookPageMapEntry? in
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapter = result.chapter
|
||||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
summaryDiskCache?.writeSynchronously(summary: summary, for: cacheKey)
|
||||
|
||||
return RDEPUBBookPageMapEntry(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.fragmentOffsets
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let lightweightEntry {
|
||||
mapBuilder.add(
|
||||
spineIndex: lightweightEntry.spineIndex,
|
||||
href: lightweightEntry.href,
|
||||
title: lightweightEntry.title,
|
||||
pageCount: lightweightEntry.pageCount,
|
||||
fragmentOffsets: lightweightEntry.fragmentOffsets
|
||||
)
|
||||
let builtCount = offset + 1
|
||||
if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count {
|
||||
lastAppliedCount = builtCount
|
||||
let partialMap = mapBuilder.build()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
let pageMap = mapBuilder.build()
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
||||
return nil
|
||||
}
|
||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
||||
return nil
|
||||
}
|
||||
let restored = summaryDiskCache.readAll(keys: catalog)
|
||||
guard restored.summaries.count == catalog.count else {
|
||||
return nil
|
||||
}
|
||||
return restored.mapBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import UIKit
|
||||
final class RDEPUBReaderRuntime {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
return loader
|
||||
}()
|
||||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
@@ -42,9 +51,11 @@ final class RDEPUBReaderRuntime {
|
||||
context.clearActiveSnapshot()
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
clearOnDemandPageModeState()
|
||||
viewportMonitor.resetForReload()
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
@@ -84,6 +95,17 @@ final class RDEPUBReaderRuntime {
|
||||
return true
|
||||
}
|
||||
|
||||
if context.bookPageMap != nil {
|
||||
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
return false
|
||||
}
|
||||
@@ -282,6 +304,40 @@ final class RDEPUBReaderRuntime {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
guard let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
let currentPage = max(readerView.currentPage, 0)
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
|
||||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
||||
readerView.reloadPageCountOnly()
|
||||
|
||||
// 用户正在选区时跳过页面跳转,避免打断选区
|
||||
if context.currentSelection == nil, bookPageMap.totalPages > 0 {
|
||||
readerView.transitionToPage(
|
||||
pageNum: min(currentPage, max(bookPageMap.totalPages - 1, 0)),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
if let currentLocation {
|
||||
locationCoordinator.persist(location: currentLocation)
|
||||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
||||
locationCoordinator.persist(location: resolvedLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
@@ -325,4 +381,174 @@ final class RDEPUBReaderRuntime {
|
||||
) {
|
||||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> 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
|
||||
}
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
|
||||
do {
|
||||
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for evictable in chapterRuntimeStore.evictableSpineIndices() {
|
||||
chapterRuntimeStore.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
let adjacent = [spineIndex - 1, spineIndex + 1].filter { publication.spine.indices.contains($0) }
|
||||
for adjacentSpineIndex in adjacent where chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil {
|
||||
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { _ in }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
|
||||
guard !nextSpineIndices.isEmpty else {
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))"
|
||||
)
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in nextSpineIndices {
|
||||
do {
|
||||
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore
|
||||
)
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadData()
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
context.bookPageMap = nil
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
EPUBPage(
|
||||
spineIndex: entry.spineIndex,
|
||||
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: entry.pageCount,
|
||||
chapterTitle: entry.title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
let chapters = bookPageMap.entries.map { entry in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: entry.spineIndex,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount
|
||||
)
|
||||
}
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user